1use 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#[derive(Debug, Clone, PartialEq)]
19pub struct Layer {
20 pub mask: Arc<Mask>,
21 pub parts: Vec<OverlayPart>,
22 pub role: Role,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Role {
28 Box,
30 Shadow,
31 Outline,
32 Fill,
33 Highlight,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct Glyph {
39 pub line: usize,
40 pub x: [f32; 2],
41 pub space: bool,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct Rendered {
48 pub layers: Vec<Layer>,
49 pub origin: [f32; 2],
50 pub size: [f32; 2],
51 pub pad: f32,
53 pub lines: Vec<f32>,
55 pub glyphs: Vec<Glyph>,
57}
58
59pub struct Typesetter {
62 fonts: FontSystem,
63 cache: SwashCache,
64 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#[derive(Debug, Clone, PartialEq)]
79pub struct LoadedFace {
80 pub family: String,
81 pub weight: Weight,
82 pub italic: bool,
83}
84
85fn 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 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 #[cfg(test)]
123 pub(crate) fn has_fonts(&self) -> bool {
124 self.fonts.db().faces().next().is_some()
125 }
126
127 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 #[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 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 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
397const MARK: Color = Color::rgb(255, 0, 255);
399
400fn 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
412fn 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
423fn dilate(m: &Mask, r: usize) -> Mask {
425 separable(m, r, |w| w.iter().copied().max().unwrap_or(0))
426}
427
428fn 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#[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}