1#![warn(missing_docs)]
11
12mod code;
13
14pub use code::{Lang, Token, highlight};
15
16use std::cell::RefCell;
17use std::hash::{Hash, Hasher};
18use std::rc::Rc;
19
20use kurbo::{Affine, BezPath, Rect};
21use parley::fontique::{Blob, FontInfoOverride};
22use parley::{
23 Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, Layout, LayoutContext,
24 LineHeight, PositionedLayoutItem, StyleProperty,
25};
26use skrifa::instance::{LocationRef, Size};
27use skrifa::outline::{DrawSettings, OutlinePen};
28use skrifa::{FontRef, GlyphId, MetadataProvider};
29
30const INTER: &[u8] = include_bytes!("../fonts/Inter.ttf");
31const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono.ttf");
32
33pub const SANS: &str = "Inter";
35pub const MONO: &str = "JetBrains Mono";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum Align {
41 #[default]
42 Start,
44 Center,
46 End,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct TextStyle {
53 pub family: String,
55 pub size: f64,
57 pub weight: f64,
59 pub italic: bool,
61 pub line_height: f64,
63 pub letter_spacing: f64,
65 pub align: Align,
67 pub max_width: Option<f64>,
69}
70
71impl Default for TextStyle {
72 fn default() -> Self {
73 Self {
74 family: SANS.to_owned(),
75 size: 48.0,
76 weight: 400.0,
77 italic: false,
78 line_height: 1.2,
79 letter_spacing: 0.0,
80 align: Align::Start,
81 max_width: None,
82 }
83 }
84}
85
86impl Hash for TextStyle {
87 fn hash<H: Hasher>(&self, h: &mut H) {
88 self.family.hash(h);
89 for v in [
90 self.size,
91 self.weight,
92 self.line_height,
93 self.letter_spacing,
94 ] {
95 v.to_bits().hash(h);
96 }
97 self.italic.hash(h);
98 self.align.hash(h);
99 self.max_width.map(f64::to_bits).hash(h);
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Default)]
105pub struct TextSpan {
106 pub text: String,
108 pub family: Option<String>,
110 pub size: Option<f64>,
112 pub weight: Option<f64>,
114 pub italic: Option<bool>,
116}
117
118impl Hash for TextSpan {
119 fn hash<H: Hasher>(&self, h: &mut H) {
120 self.text.hash(h);
121 self.family.hash(h);
122 self.size.map(f64::to_bits).hash(h);
123 self.weight.map(f64::to_bits).hash(h);
124 self.italic.hash(h);
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct GlyphInfo {
131 pub span: u16,
133 pub ch: char,
135 pub origin: kurbo::Point,
137}
138
139#[derive(Debug, Clone)]
142pub struct TextLayout {
143 pub path: BezPath,
145 pub glyphs: Vec<BezPath>,
147 pub info: Vec<GlyphInfo>,
149 pub width: f64,
151 pub height: f64,
153}
154
155impl TextLayout {
156 pub fn bounds(&self) -> Rect {
158 Rect::new(0.0, 0.0, self.width, self.height)
159 }
160}
161
162struct Fonts {
163 font_cx: FontContext,
164 layout_cx: LayoutContext<u16>,
165 cache: Vec<(u64, Rc<TextLayout>)>,
166}
167
168const CACHE_SIZE: usize = 256;
169
170thread_local! {
171 static FONTS: RefCell<Fonts> = RefCell::new(Fonts::new());
172}
173
174impl Fonts {
175 fn new() -> Self {
176 let mut font_cx = FontContext::new();
177 for data in [INTER, JETBRAINS_MONO] {
178 font_cx
179 .collection
180 .register_fonts(Blob::new(std::sync::Arc::new(data)), None);
181 }
182 Self {
183 font_cx,
184 layout_cx: LayoutContext::new(),
185 cache: Vec::new(),
186 }
187 }
188}
189
190pub fn register_font(data: &'static [u8]) -> Vec<String> {
193 FONTS.with(|f| {
194 let mut f = f.borrow_mut();
195 f.cache.clear();
196 let collection = &mut f.font_cx.collection;
197 let families = collection.register_fonts(
198 Blob::new(std::sync::Arc::new(data)),
199 None::<FontInfoOverride<'_>>,
200 );
201 families
202 .into_iter()
203 .filter_map(|(id, _)| collection.family_name(id).map(str::to_owned))
204 .collect()
205 })
206}
207
208pub fn layout(text: &str, style: &TextStyle) -> Rc<TextLayout> {
210 layout_rich(
211 &[TextSpan {
212 text: text.to_owned(),
213 ..Default::default()
214 }],
215 style,
216 )
217}
218
219pub fn layout_rich(spans: &[TextSpan], style: &TextStyle) -> Rc<TextLayout> {
221 let mut h = std::hash::DefaultHasher::new();
222 spans.hash(&mut h);
223 style.hash(&mut h);
224 let key = h.finish();
225 FONTS.with(|f| {
226 let mut f = f.borrow_mut();
227 if let Some(pos) = f.cache.iter().position(|(k, _)| *k == key) {
228 let entry = f.cache.remove(pos);
229 let value = entry.1.clone();
230 f.cache.push(entry);
231 return value;
232 }
233 let value = Rc::new(build(&mut f, spans, style));
234 if f.cache.len() >= CACHE_SIZE {
235 f.cache.remove(0);
236 }
237 f.cache.push((key, value.clone()));
238 value
239 })
240}
241
242fn build(f: &mut Fonts, spans: &[TextSpan], style: &TextStyle) -> TextLayout {
243 let Fonts {
244 font_cx, layout_cx, ..
245 } = f;
246 let text: String = spans.iter().map(|s| s.text.as_str()).collect();
247 let text = text.as_str();
248 let mut b = layout_cx.ranged_builder(font_cx, text, 1.0, true);
249 b.push_default(StyleProperty::FontFamily(FontFamily::named(&style.family)));
250 b.push_default(StyleProperty::FontSize(style.size as f32));
251 b.push_default(StyleProperty::FontWeight(FontWeight::new(
252 style.weight as f32,
253 )));
254 if style.italic {
255 b.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic));
256 }
257 b.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
258 style.line_height as f32,
259 )));
260 b.push_default(StyleProperty::LetterSpacing(style.letter_spacing as f32));
261 b.push_default(StyleProperty::Brush(0u16));
262 let mut at = 0;
263 for (i, span) in spans.iter().enumerate() {
264 let r = at..at + span.text.len();
265 at = r.end;
266 if r.is_empty() {
267 continue;
268 }
269 b.push(StyleProperty::Brush(i as u16), r.clone());
270 if let Some(f) = &span.family {
271 b.push(StyleProperty::FontFamily(FontFamily::named(f)), r.clone());
272 }
273 if let Some(sz) = span.size {
274 b.push(StyleProperty::FontSize(sz as f32), r.clone());
275 }
276 if let Some(w) = span.weight {
277 b.push(
278 StyleProperty::FontWeight(FontWeight::new(w as f32)),
279 r.clone(),
280 );
281 }
282 if let Some(it) = span.italic {
283 let st = if it {
284 parley::FontStyle::Italic
285 } else {
286 parley::FontStyle::Normal
287 };
288 b.push(StyleProperty::FontStyle(st), r.clone());
289 }
290 }
291 let mut layout: Layout<u16> = b.build(text);
292 layout.break_all_lines(style.max_width.map(|w| w as f32));
293 let align = match style.align {
294 Align::Start => Alignment::Start,
295 Align::Center => Alignment::Center,
296 Align::End => Alignment::End,
297 };
298 layout.align(align, AlignmentOptions::default());
299
300 let mut path = BezPath::new();
301 let mut glyphs = Vec::new();
302 let mut info = Vec::new();
303 for line in layout.lines() {
304 for item in line.items() {
305 let PositionedLayoutItem::GlyphRun(run) = item else {
306 continue;
307 };
308 let r = run.run();
309 let span = run.style().brush;
310 let chars: Vec<char> = r
312 .visual_clusters()
313 .flat_map(|c| {
314 let ch = text[c.text_range()].chars().next().unwrap_or('\0');
315 std::iter::repeat_n(ch, c.glyphs().count())
316 })
317 .collect();
318 let font = r.font();
319 let Ok(font_ref) = FontRef::from_index(font.data.as_ref(), font.index) else {
320 continue;
321 };
322 let outlines = font_ref.outline_glyphs();
323 let coords = r.normalized_coords();
324 let size = Size::new(r.font_size());
325 let coords: Vec<skrifa::instance::NormalizedCoord> = coords
326 .iter()
327 .map(|c| skrifa::raw::types::F2Dot14::from_bits(c.to_bits()))
328 .collect();
329 let location = LocationRef::new(&coords);
330 let skew = r
332 .synthesis()
333 .skew()
334 .map_or(0.0, |deg| f64::from(deg).to_radians().tan());
335 for (gi, g) in run.positioned_glyphs().enumerate() {
336 let Some(outline) = outlines.get(GlyphId::new(g.id)) else {
337 continue;
338 };
339 let mut pen = Pen(BezPath::new());
340 if outline
341 .draw(DrawSettings::unhinted(size, location), &mut pen)
342 .is_err()
343 {
344 continue;
345 }
346 let tf = Affine::translate((f64::from(g.x), f64::from(g.y)))
348 * Affine::scale_non_uniform(1.0, -1.0)
349 * Affine::skew(skew, 0.0);
350 let glyph = tf * pen.0;
351 if !glyph.elements().is_empty() {
352 path.extend(glyph.elements().iter().copied());
353 glyphs.push(glyph);
354 info.push(GlyphInfo {
355 span,
356 ch: chars.get(gi).copied().unwrap_or('\0'),
357 origin: kurbo::Point::new(f64::from(g.x), f64::from(g.y)),
358 });
359 }
360 }
361 }
362 }
363 TextLayout {
364 path,
365 glyphs,
366 info,
367 width: f64::from(layout.width()),
368 height: f64::from(layout.height()),
369 }
370}
371
372struct Pen(BezPath);
373
374impl OutlinePen for Pen {
375 fn move_to(&mut self, x: f32, y: f32) {
376 self.0.move_to((f64::from(x), f64::from(y)));
377 }
378 fn line_to(&mut self, x: f32, y: f32) {
379 self.0.line_to((f64::from(x), f64::from(y)));
380 }
381 fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
382 self.0.quad_to(
383 (f64::from(cx0), f64::from(cy0)),
384 (f64::from(x), f64::from(y)),
385 );
386 }
387 fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
388 self.0.curve_to(
389 (f64::from(cx0), f64::from(cy0)),
390 (f64::from(cx1), f64::from(cy1)),
391 (f64::from(x), f64::from(y)),
392 );
393 }
394 fn close(&mut self) {
395 self.0.close_path();
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use kurbo::Shape;
402
403 use super::*;
404
405 #[test]
406 fn lays_out_text_with_embedded_font() {
407 let l = layout("Hello", &TextStyle::default());
408 assert_eq!(l.glyphs.len(), 5);
409 assert!(l.width > 100.0 && l.width < 160.0, "width {}", l.width);
410 let bb = l.path.bounding_box();
411 assert!(bb.y0 >= 0.0 && bb.y1 <= l.height, "{bb:?} h={}", l.height);
412 }
413
414 #[test]
415 fn mono_and_weight_change_shapes() {
416 let s = TextStyle::default();
417 let regular = layout("iii", &s);
418 let mono = layout(
419 "iii",
420 &TextStyle {
421 family: MONO.into(),
422 ..s.clone()
423 },
424 );
425 let bold = layout("iii", &TextStyle { weight: 800.0, ..s });
426 assert!(mono.width > regular.width);
427 assert_ne!(bold.path, regular.path);
428 }
429
430 #[test]
431 fn wraps_and_centers() {
432 let s = TextStyle {
433 max_width: Some(120.0),
434 align: Align::Center,
435 ..Default::default()
436 };
437 let l = layout("one two three four", &s);
438 assert!(l.height > 2.0 * 48.0);
439 }
440
441 #[test]
442 fn rich_spans_carry_index_and_chars() {
443 let spans = [
444 TextSpan {
445 text: "ab ".into(),
446 ..Default::default()
447 },
448 TextSpan {
449 text: "cd".into(),
450 weight: Some(800.0),
451 ..Default::default()
452 },
453 ];
454 let l = layout_rich(&spans, &TextStyle::default());
455 let got: Vec<(u16, char)> = l.info.iter().map(|i| (i.span, i.ch)).collect();
456 assert_eq!(got, vec![(0, 'a'), (0, 'b'), (1, 'c'), (1, 'd')]);
457 assert!(l.info[1].origin.x > l.info[0].origin.x);
458 }
459
460 #[test]
461 fn cyrillic_is_supported() {
462 let l = layout("Привет", &TextStyle::default());
463 assert_eq!(l.glyphs.len(), 6);
464 }
465}