Skip to main content

ffcine_text/
layout.rs

1//! Laying out and rasterising text with `cosmic-text`: one coverage mask per text, plus derived
2//! masks for the shadow (blurred) and outline (dilated), as [`Layer`]s ready to be overlays.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use cosmic_text::{
9    Align as CAlign, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache,
10    Weight,
11};
12use ffcine_core::{Error, Result};
13use ffcine_render::{Mask, OverlayPart};
14
15use crate::style::{Align, TextStyle};
16
17/// One mask and where to draw it, bottom layer first.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Layer {
20    pub mask: Arc<Mask>,
21    pub parts: Vec<OverlayPart>,
22    pub role: Role,
23}
24
25/// What a [`Layer`] draws.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Role {
28    /// A solid box behind the text (or a `rect` clip's rectangle): a 1x1 mask stretched.
29    Box,
30    Shadow,
31    Outline,
32    Fill,
33    Highlight,
34}
35
36/// A grapheme cluster of the laid-out text: `[x0, x1]` along its line, in text box px.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct Glyph {
39    pub line: usize,
40    pub x: [f32; 2],
41    /// Whitespace (ends a word).
42    pub space: bool,
43}
44
45/// A laid-out text: its layers and the text box (top left, size) in output px.
46#[derive(Debug, Clone, PartialEq)]
47pub struct Rendered {
48    pub layers: Vec<Layer>,
49    pub origin: [f32; 2],
50    pub size: [f32; 2],
51    /// Margin around the text box in every mask (blur, outline), px.
52    pub pad: f32,
53    /// Top of each line in the text box, px, plus the box's bottom.
54    pub lines: Vec<f32>,
55    /// Clusters in reading order.
56    pub glyphs: Vec<Glyph>,
57}
58
59/// Font database, glyph cache and loaded font files. Building one scans the system fonts, so
60/// keep it for every text of a render.
61pub struct Typesetter {
62    fonts: FontSystem,
63    cache: SwashCache,
64    /// (font file, face index) -> the face's family, weight and slant.
65    files: HashMap<(PathBuf, u32), LoadedFace>,
66    base_dir: PathBuf,
67}
68
69impl std::fmt::Debug for Typesetter {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("Typesetter")
72            .field("files", &self.files)
73            .finish_non_exhaustive()
74    }
75}
76
77/// A face loaded from a font file.
78#[derive(Debug, Clone, PartialEq)]
79pub struct LoadedFace {
80    pub family: String,
81    pub weight: Weight,
82    pub italic: bool,
83}
84
85/// `Light`, `Medium`, `SemiBold Italic`, ... -> weight and slant.
86fn face(name: &str) -> (Option<Weight>, bool) {
87    let n = name.to_ascii_lowercase().replace([' ', '-'], "");
88    let w = [
89        ("thin", 100),
90        ("extralight", 200),
91        ("ultralight", 200),
92        ("light", 300),
93        ("regular", 400),
94        ("book", 400),
95        ("medium", 500),
96        ("semibold", 600),
97        ("demibold", 600),
98        ("extrabold", 800),
99        ("ultrabold", 800),
100        ("black", 900),
101        ("heavy", 900),
102        ("bold", 700),
103    ]
104    .iter()
105    .find(|(k, _)| n.contains(k))
106    .map(|(_, w)| Weight(*w));
107    (w, n.contains("italic") || n.contains("oblique"))
108}
109
110impl Typesetter {
111    /// System fonts plus files given relative to `base_dir` (the timeline's directory).
112    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
113        Self {
114            fonts: FontSystem::new(),
115            cache: SwashCache::new(),
116            files: HashMap::new(),
117            base_dir: base_dir.into(),
118        }
119    }
120
121    /// Whether any font is loaded (tests skip without one).
122    #[cfg(test)]
123    pub(crate) fn has_fonts(&self) -> bool {
124        self.fonts.db().faces().next().is_some()
125    }
126
127    /// Loads a font file (once) and returns face `index` of it (0 unless a `.ttc`).
128    pub fn load(&mut self, file: &Path, index: u32) -> Result<LoadedFace> {
129        let path = if file.is_absolute() {
130            file.to_owned()
131        } else {
132            self.base_dir.join(file)
133        };
134        if let Some(f) = self.files.get(&(path.clone(), index)) {
135            return Ok(f.clone());
136        }
137        let data = std::fs::read(&path).map_err(|source| Error::Io {
138            path: path.clone(),
139            source,
140        })?;
141        let db = self.fonts.db_mut();
142        let before: Vec<_> = db.faces().map(|f| f.id).collect();
143        db.load_font_data(data);
144        for f in db.faces().filter(|f| !before.contains(&f.id)) {
145            if let Some((family, _)) = f.families.first() {
146                self.files.insert(
147                    (path.clone(), f.index),
148                    LoadedFace {
149                        family: family.clone(),
150                        weight: Weight(f.weight.0),
151                        italic: f.style != Style::Normal,
152                    },
153                );
154            }
155        }
156        self.files
157            .get(&(path.clone(), index))
158            .cloned()
159            .ok_or_else(|| Error::Invalid {
160                what: "font file and index",
161                value: format!("{} #{index}", path.display()),
162            })
163    }
164
165    /// Lays out `text` in `style` for an `out` = `[w, h]` px frame, lengths scaled by `scale`
166    /// (output height / `frame_size` height).
167    #[allow(
168        clippy::cast_possible_truncation,
169        clippy::cast_sign_loss,
170        clippy::cast_precision_loss,
171        clippy::cast_possible_wrap,
172        clippy::too_many_lines
173    )]
174    pub fn render(
175        &mut self,
176        text: &str,
177        style: &TextStyle,
178        scale: f32,
179        out: [u32; 2],
180    ) -> Result<Option<Rendered>> {
181        let text = if style.uppercase {
182            text.to_uppercase()
183        } else {
184            text.to_owned()
185        };
186        if text.trim().is_empty() {
187            return Ok(None);
188        }
189        let loaded = match &style.font_file {
190            Some(f) => Some(self.load(f, style.font_index)?),
191            None => None,
192        };
193        let family_name = loaded
194            .as_ref()
195            .map(|l| l.family.clone())
196            .or_else(|| style.font.clone());
197        let (fw, fh) = (out[0] as f32, out[1] as f32);
198        let size = (style.size * scale).max(1.0);
199        let (face_weight, face_italic) = match (&loaded, style.face.as_deref()) {
200            (_, Some(f)) => face(f),
201            (Some(l), None) => (Some(l.weight), l.italic),
202            (None, None) => (None, false),
203        };
204        let mut attrs = Attrs::new()
205            .family(
206                family_name
207                    .as_deref()
208                    .map_or(Family::SansSerif, Family::Name),
209            )
210            .weight(if style.bold {
211                Weight::BOLD
212            } else {
213                face_weight.unwrap_or(Weight::NORMAL)
214            })
215            .style(if style.italic || face_italic {
216                Style::Italic
217            } else {
218                Style::Normal
219            });
220        if style.tracking != 0.0 {
221            attrs = attrs.letter_spacing(style.tracking);
222        }
223        let align = match style.alignment {
224            Align::Left => CAlign::Left,
225            Align::Center => CAlign::Center,
226            Align::Right => CAlign::Right,
227        };
228        let mut buf = Buffer::new(
229            &mut self.fonts,
230            Metrics::new(size, size * style.line_height),
231        );
232        buf.set_size(Some(style.max_width * fw), None);
233        let spans = spans(&text, style.highlight.is_some());
234        buf.set_rich_text(
235            spans.iter().map(|(t, hi)| {
236                (
237                    t.as_str(),
238                    if *hi {
239                        attrs.clone().color(MARK)
240                    } else {
241                        attrs.clone()
242                    },
243                )
244            }),
245            &attrs,
246            Shaping::Advanced,
247            Some(align),
248        );
249        buf.shape_until_scroll(&mut self.fonts, false);
250        // Shrink the box to the widest line so alignment is within the text, not the wrap width.
251        let width = buf
252            .layout_runs()
253            .map(|r| r.line_w)
254            .fold(0.0, f32::max)
255            .ceil();
256        buf.set_size(Some(width.max(1.0)), None);
257        buf.shape_until_scroll(&mut self.fonts, false);
258        let height = buf
259            .layout_runs()
260            .map(|r| r.line_top + r.line_height)
261            .fold(0.0, f32::max)
262            .ceil();
263        let mut lines = Vec::new();
264        let mut glyphs = Vec::new();
265        for (line, run) in buf.layout_runs().enumerate() {
266            lines.push(run.line_top);
267            let mut gs: Vec<Glyph> = Vec::new();
268            let mut last = None;
269            for g in run.glyphs {
270                let space = run
271                    .text
272                    .get(g.start..g.end)
273                    .is_some_and(|t| t.trim().is_empty());
274                match gs.last_mut() {
275                    // One cluster shaped into several glyphs.
276                    Some(c) if last == Some(g.start) => {
277                        c.x = [c.x[0].min(g.x), c.x[1].max(g.x + g.w)];
278                    }
279                    _ => gs.push(Glyph {
280                        line,
281                        x: [g.x, g.x + g.w],
282                        space,
283                    }),
284                }
285                last = Some(g.start);
286            }
287            gs.sort_by(|a, b| a.x[0].total_cmp(&b.x[0]));
288            glyphs.extend(gs);
289        }
290        lines.push(height);
291
292        let outline = style.outline.as_ref().map_or(0.0, |o| o.width * scale);
293        let blur = style.shadow.as_ref().map_or(0.0, |s| s.blur * scale);
294        let pad = (outline + 2.0 * blur + 0.5 * size).ceil() as u32 + 2;
295        let (mw, mh) = (width as u32 + 2 * pad, height as u32 + 2 * pad);
296        let mut fill = Mask::new(mw, mh);
297        let mut marked = style.highlight.map(|_| Mask::new(mw, mh));
298        buf.draw(
299            &mut self.fonts,
300            &mut self.cache,
301            Color::rgb(255, 255, 255),
302            |x, y, w, h, c| {
303                let a = c.a();
304                let fill = match &mut marked {
305                    Some(m) if (c.r(), c.g(), c.b()) == (MARK.r(), MARK.g(), MARK.b()) => m,
306                    _ => &mut fill,
307                };
308                for dy in 0..h as i32 {
309                    for dx in 0..w as i32 {
310                        let (px, py) = (x + dx + pad as i32, y + dy + pad as i32);
311                        if px >= 0 && py >= 0 && (px as u32) < mw && (py as u32) < mh {
312                            let i = py as usize * mw as usize + px as usize;
313                            fill.data[i] = fill.data[i].max(a);
314                        }
315                    }
316                }
317            },
318        );
319
320        let anchor = [
321            (style.position[0] + 0.5) * fw,
322            (style.position[1] + 0.5) * fh,
323        ];
324        let origin = [
325            (anchor[0] - style.anchor[0] * width).round(),
326            (anchor[1] - style.anchor[1] * height).round(),
327        ];
328        let at = [origin[0] - pad as f32, origin[1] - pad as f32];
329        let part = |pos: [f32; 2], colour: [f32; 4]| OverlayPart {
330            pos,
331            colour,
332            ..OverlayPart::default()
333        };
334        let mut layers = Vec::new();
335        if let Some(b) = &style.box_ {
336            let p = b.padding * scale;
337            layers.push(Layer {
338                mask: Arc::new(Mask::solid()),
339                parts: vec![OverlayPart {
340                    pos: [origin[0] - p, origin[1] - p],
341                    size: Some([width + 2.0 * p, height + 2.0 * p]),
342                    colour: b.colour,
343                    src: None,
344                }],
345                role: Role::Box,
346            });
347        }
348        if let Some(s) = &style.shadow {
349            let all = union(&fill, marked.as_ref());
350            let src = match &style.outline {
351                Some(o) => dilate(&all, (o.width * scale).round() as usize),
352                None => all,
353            };
354            let m = blur_mask(&src, blur.round() as usize);
355            layers.push(Layer {
356                mask: Arc::new(m),
357                parts: vec![part(
358                    [at[0] + s.offset[0] * scale, at[1] + s.offset[1] * scale],
359                    s.colour,
360                )],
361                role: Role::Shadow,
362            });
363        }
364        if let Some(o) = &style.outline {
365            layers.push(Layer {
366                mask: Arc::new(dilate(
367                    &union(&fill, marked.as_ref()),
368                    (o.width * scale).round() as usize,
369                )),
370                parts: vec![part(at, o.colour)],
371                role: Role::Outline,
372            });
373        }
374        layers.push(Layer {
375            mask: Arc::new(fill),
376            parts: vec![part(at, style.colour)],
377            role: Role::Fill,
378        });
379        if let (Some(m), Some(c)) = (marked, style.highlight) {
380            layers.push(Layer {
381                mask: Arc::new(m),
382                parts: vec![part(at, c)],
383                role: Role::Highlight,
384            });
385        }
386        Ok(Some(Rendered {
387            layers,
388            origin,
389            size: [width, height],
390            pad: pad as f32,
391            lines,
392            glyphs,
393        }))
394    }
395}
396
397/// Glyph colour that marks highlighted spans while rasterising (never drawn as is).
398const MARK: Color = Color::rgb(255, 0, 255);
399
400/// `a *b* c` -> `[("a ", false), ("b", true), (" c", false)]` when `marks`, else one span.
401fn spans(text: &str, marks: bool) -> Vec<(String, bool)> {
402    if !marks {
403        return vec![(text.to_owned(), false)];
404    }
405    text.split('*')
406        .enumerate()
407        .filter(|(_, s)| !s.is_empty())
408        .map(|(i, s)| (s.to_owned(), i % 2 == 1))
409        .collect()
410}
411
412/// Per-pixel max of two masks of the same size.
413fn union(a: &Mask, b: Option<&Mask>) -> Mask {
414    let mut out = a.clone();
415    if let Some(b) = b {
416        for (o, v) in out.data.iter_mut().zip(&b.data) {
417            *o = (*o).max(*v);
418        }
419    }
420    out
421}
422
423/// Separable max filter, radius `r` (a square outline).
424fn dilate(m: &Mask, r: usize) -> Mask {
425    separable(m, r, |w| w.iter().copied().max().unwrap_or(0))
426}
427
428/// Three box blurs of radius `r / 2` (close to a Gaussian of that radius).
429fn blur_mask(m: &Mask, r: usize) -> Mask {
430    let k = r.div_ceil(2);
431    let mut out = m.clone();
432    for _ in 0..3 {
433        out = separable(&out, k, |w| {
434            let sum: usize = w.iter().map(|&v| usize::from(v)).sum();
435            u8::try_from(sum / w.len()).unwrap_or(u8::MAX)
436        });
437    }
438    out
439}
440
441/// Applies `f` over a `2r+1` window along rows, then columns; outside the mask is 0.
442#[allow(clippy::many_single_char_names)]
443fn separable(m: &Mask, r: usize, f: impl Fn(&[u8]) -> u8) -> Mask {
444    if r == 0 {
445        return m.clone();
446    }
447    let (w, h) = (m.width as usize, m.height as usize);
448    let pass = |src: &[u8], len: usize, lines: usize, at: &dyn Fn(usize, usize) -> usize| {
449        let mut dst = vec![0u8; src.len()];
450        let mut win = vec![0u8; 2 * r + 1];
451        for l in 0..lines {
452            for i in 0..len {
453                for (k, slot) in win.iter_mut().enumerate() {
454                    let j = (i + k).checked_sub(r).filter(|j| *j < len);
455                    *slot = j.map_or(0, |j| src[at(l, j)]);
456                }
457                dst[at(l, i)] = f(&win);
458            }
459        }
460        dst
461    };
462    let rows = pass(&m.data, w, h, &|y, x| y * w + x);
463    let data = pass(&rows, h, w, &|x, y| y * w + x);
464    Mask {
465        width: m.width,
466        height: m.height,
467        data,
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn highlight_spans() {
477        assert_eq!(
478            spans("on this *payday,* babe", true),
479            [
480                ("on this ".to_owned(), false),
481                ("payday,".to_owned(), true),
482                (" babe".to_owned(), false)
483            ]
484        );
485        assert_eq!(spans("a*b", false), [("a*b".to_owned(), false)]);
486    }
487
488    #[test]
489    fn faces() {
490        assert_eq!(face("SemiBold Italic"), (Some(Weight(600)), true));
491        assert_eq!(face("Light"), (Some(Weight(300)), false));
492        assert_eq!(face("ExtraBold"), (Some(Weight(800)), false));
493    }
494
495    #[test]
496    fn dilate_and_blur() {
497        let mut m = Mask::new(7, 7);
498        m.data[3 * 7 + 3] = 255;
499        let d = dilate(&m, 1);
500        assert_eq!(d.data.iter().map(|v| u32::from(*v)).sum::<u32>(), 9 * 255);
501        let b = blur_mask(&d, 2);
502        assert!(b.data[3 * 7 + 3] > 0 && b.data[3 * 7 + 3] < 255);
503        assert!(b.data[0] < b.data[3 * 7 + 3]);
504    }
505
506    #[test]
507    fn lays_out_with_system_fonts() {
508        let mut t = Typesetter::new(".");
509        if t.fonts.db().faces().next().is_none() {
510            eprintln!("no system fonts; skipped");
511            return;
512        }
513        let mut s = TextStyle::default_for(1080.0);
514        s.position = [-0.45, -0.4];
515        s.anchor = [0.0, 0.0];
516        s.shadow = Some(crate::style::Shadow {
517            colour: [0.0, 0.0, 0.0, 0.5],
518            offset: [0.0, 2.0],
519            blur: 4.0,
520        });
521        let r = t
522            .render("Arrival\nOld Town", &s, 1.0, [1920, 1080])
523            .expect("render")
524            .expect("text");
525        assert_eq!(r.origin, [96.0, 108.0]);
526        assert!(r.size[1] > 2.0 * 54.0 * 0.9, "two lines: {:?}", r.size);
527        assert_eq!(r.layers.len(), 2);
528        let fill = &r.layers[1].mask;
529        assert!(fill.data.iter().any(|v| *v > 200), "glyphs drawn");
530    }
531}