1 // This Source Code Form is subject to the terms of the Mozilla Public
2 // License, v. 2.0. If a copy of the MPL was not distributed with this
3 // file, You can obtain one at http://mozilla.org/MPL/2.0/.
4 
5 use kurbo::{ParamCurveArclen, ParamCurve, ParamCurveDeriv};
6 use harfbuzz_rs as harfbuzz;
7 use unicode_vo::Orientation as CharOrientation;
8 use unicode_script::UnicodeScript;
9 use ttf_parser::GlyphId;
10 
11 use crate::{tree, fontdb, convert::prelude::*};
12 use crate::tree::CubicBezExt;
13 use super::convert::{
14     ByteIndex,
15     CharacterPosition,
16     TextAnchor,
17     TextChunk,
18     TextFlow,
19     TextPath,
20     WritingMode,
21 };
22 
23 
24 /// A glyph.
25 ///
26 /// Basically, a glyph ID and it's metrics.
27 #[derive(Clone)]
28 struct Glyph {
29     /// The glyph ID in the font.
30     id: GlyphId,
31 
32     /// Position in bytes in the original string.
33     ///
34     /// We use it to match a glyph with a character in the text chunk and therefore with the style.
35     byte_idx: ByteIndex,
36 
37     /// The glyph offset in font units.
38     dx: i32,
39 
40     /// The glyph offset in font units.
41     dy: i32,
42 
43     /// The glyph width / X-advance in font units.
44     width: i32,
45 
46     /// Reference to the source font.
47     ///
48     /// Each glyph can have it's own source font.
49     font: fontdb::Font,
50 }
51 
52 impl Glyph {
is_missing(&self) -> bool53     fn is_missing(&self) -> bool {
54         self.id.0 == 0
55     }
56 }
57 
58 
59 /// An outlined cluster.
60 ///
61 /// Cluster/grapheme is a single, unbroken, renderable character.
62 /// It can be positioned, rotated, spaced, etc.
63 ///
64 /// Let's say we have `й` which is *CYRILLIC SMALL LETTER I* and *COMBINING BREVE*.
65 /// It consists of two code points, will be shaped (via harfbuzz) as two glyphs into one cluster,
66 /// and then will be combined into the one `OutlinedCluster`.
67 #[derive(Clone)]
68 pub struct OutlinedCluster {
69     /// Position in bytes in the original string.
70     ///
71     /// We use it to match a cluster with a character in the text chunk and therefore with the style.
72     pub byte_idx: ByteIndex,
73 
74     /// Cluster's original codepoint.
75     ///
76     /// Technically, a cluster can contain multiple codepoints,
77     /// but we are storing only the first one.
78     pub codepoint: char,
79 
80     /// An advance along the X axis.
81     ///
82     /// Can be negative.
83     pub advance: f64,
84 
85     /// An ascent in SVG coordinates.
86     pub ascent: f64,
87 
88     /// A descent in SVG coordinates.
89     pub descent: f64,
90 
91     /// A x-height in SVG coordinates.
92     pub x_height: f64,
93 
94     /// Indicates that this cluster was affected by the relative shift (via dx/dy attributes)
95     /// during the text layouting. Which breaks the `text-decoration` line.
96     ///
97     /// Used during the `text-decoration` processing.
98     pub has_relative_shift: bool,
99 
100     /// An actual outline.
101     pub path: tree::PathData,
102 
103     /// A cluster's transform that contains it's position, rotation, etc.
104     pub transform: tree::Transform,
105 
106     /// Not all clusters should be rendered.
107     ///
108     /// For example, if a cluster is outside the text path than it should not be rendered.
109     pub visible: bool,
110 }
111 
112 impl OutlinedCluster {
height(&self) -> f64113     pub fn height(&self) -> f64 {
114         self.ascent - self.descent
115     }
116 }
117 
118 
119 /// An iterator over glyph clusters.
120 ///
121 /// Input:  0 2 2 2 3 4 4 5 5
122 /// Result: 0 1     4 5   7
123 struct GlyphClusters<'a> {
124     data: &'a [Glyph],
125     idx: usize,
126 }
127 
128 impl<'a> GlyphClusters<'a> {
new(data: &'a [Glyph]) -> Self129     fn new(data: &'a [Glyph]) -> Self {
130         GlyphClusters { data, idx: 0 }
131     }
132 }
133 
134 impl<'a> Iterator for GlyphClusters<'a> {
135     type Item = (std::ops::Range<usize>, ByteIndex);
136 
next(&mut self) -> Option<Self::Item>137     fn next(&mut self) -> Option<Self::Item> {
138         if self.idx == self.data.len() {
139             return None;
140         }
141 
142         let start = self.idx;
143         let cluster = self.data[self.idx].byte_idx;
144         for g in &self.data[self.idx..] {
145             if g.byte_idx != cluster {
146                 break;
147             }
148 
149             self.idx += 1;
150         }
151 
152         Some((start..self.idx, cluster))
153     }
154 }
155 
156 
157 /// Converts a text chunk into a list of outlined clusters.
158 ///
159 /// This function will do the BIDI reordering, text shaping and glyphs outlining,
160 /// but not the text layouting. So all clusters are in the 0x0 position.
outline_chunk( chunk: &TextChunk, state: &State, ) -> Vec<OutlinedCluster>161 pub fn outline_chunk(
162     chunk: &TextChunk,
163     state: &State,
164 ) -> Vec<OutlinedCluster> {
165     let mut glyphs = Vec::new();
166     for span in &chunk.spans {
167         let tmp_glyphs = shape_text(&chunk.text, span.font, state);
168 
169         // Do nothing with the first run.
170         if glyphs.is_empty() {
171             glyphs = tmp_glyphs;
172             continue;
173         }
174 
175         // We assume, that shaping with an any font will produce the same amount of glyphs.
176         // Otherwise an error.
177         if glyphs.len() != tmp_glyphs.len() {
178             warn!("Text layouting failed.");
179             return Vec::new();
180         }
181 
182         // Copy span's glyphs.
183         for (i, glyph) in tmp_glyphs.iter().enumerate() {
184             if span.contains(glyph.byte_idx) {
185                 glyphs[i] = glyph.clone();
186             }
187         }
188     }
189 
190     // Convert glyphs to clusters.
191     let mut clusters = Vec::new();
192     for (range, byte_idx) in GlyphClusters::new(&glyphs) {
193         if let Some(span) = chunk.span_at(byte_idx) {
194             let db = state.db.borrow();
195             clusters.push(outline_cluster(&glyphs[range], &chunk.text, span.font_size, &db));
196         }
197     }
198 
199     clusters
200 }
201 
202 /// Text shaping with font fallback.
shape_text( text: &str, font: fontdb::Font, state: &State, ) -> Vec<Glyph>203 fn shape_text(
204     text: &str,
205     font: fontdb::Font,
206     state: &State,
207 ) -> Vec<Glyph> {
208     let mut glyphs = shape_text_with_font(text, font, state).unwrap_or_default();
209 
210     // Remember all fonts used for shaping.
211     let mut used_fonts = vec![font.id];
212 
213     // Loop until all glyphs become resolved or until no more fonts are left.
214     'outer: loop {
215         let mut missing = None;
216         for glyph in &glyphs {
217             if glyph.is_missing() {
218                 missing = Some(glyph.byte_idx.char_from(text));
219                 break;
220             }
221         }
222 
223         if let Some(c) = missing {
224             let fallback_font = match find_font_for_char(c, &used_fonts, state) {
225                 Some(v) => v,
226                 None => break 'outer,
227             };
228 
229             // Shape again, using a new font.
230             let fallback_glyphs = shape_text_with_font(text, fallback_font, state)
231                 .unwrap_or_default();
232 
233             // We assume, that shaping with an any font will produce the same amount of glyphs.
234             // Otherwise an error.
235             if glyphs.len() != fallback_glyphs.len() {
236                 break 'outer;
237             }
238 
239             // Copy new glyphs.
240             for i in 0..glyphs.len() {
241                 if glyphs[i].is_missing() && !fallback_glyphs[i].is_missing() {
242                     glyphs[i] = fallback_glyphs[i].clone();
243                 }
244             }
245 
246             // Remember this font.
247             used_fonts.push(fallback_font.id);
248         } else {
249             break 'outer;
250         }
251     }
252 
253     // Warn about missing glyphs.
254     for glyph in &glyphs {
255         if glyph.is_missing() {
256             let c = glyph.byte_idx.char_from(text);
257             // TODO: print a full grapheme
258             warn!("No fonts with a {}/U+{:X} character were found.", c, c as u32);
259         }
260     }
261 
262     glyphs
263 }
264 
265 /// Converts a text into a list of glyph IDs.
266 ///
267 /// This function will do the BIDI reordering and text shaping.
shape_text_with_font( text: &str, font: fontdb::Font, state: &State, ) -> Option<Vec<Glyph>>268 fn shape_text_with_font(
269     text: &str,
270     font: fontdb::Font,
271     state: &State,
272 ) -> Option<Vec<Glyph>> {
273     let db = state.db.borrow();
274 
275     // We can't simplify this code because of lifetimes.
276     let item = db.font(font.id);
277     let file = std::fs::File::open(&item.path).ok()?;
278     let mmap = unsafe { memmap2::MmapOptions::new().map(&file).ok()? };
279 
280     let hb_face = harfbuzz::Face::from_bytes(&mmap, item.face_index);
281     let hb_font = harfbuzz::Font::new(hb_face);
282 
283     let bidi_info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr()));
284     let paragraph = &bidi_info.paragraphs[0];
285     let line = paragraph.range.clone();
286 
287     let mut glyphs = Vec::new();
288 
289     let (levels, runs) = bidi_info.visual_runs(&paragraph, line);
290     for run in runs.iter() {
291         let sub_text = &text[run.clone()];
292         if sub_text.is_empty() {
293             continue;
294         }
295 
296         let hb_direction = if levels[run.start].is_rtl() {
297             harfbuzz::Direction::Rtl
298         } else {
299             harfbuzz::Direction::Ltr
300         };
301 
302         let buffer = harfbuzz::UnicodeBuffer::new()
303             .add_str(sub_text)
304             .set_direction(hb_direction);
305 
306         let output = harfbuzz::shape(&hb_font, buffer, &[]);
307 
308         let positions = output.get_glyph_positions();
309         let infos = output.get_glyph_infos();
310 
311         for (pos, info) in positions.iter().zip(infos) {
312             let idx = run.start + info.cluster as usize;
313             debug_assert!(text.get(idx..).is_some());
314 
315             glyphs.push(Glyph {
316                 byte_idx: ByteIndex::new(idx),
317                 id: GlyphId(info.codepoint as u16),
318                 dx: pos.x_offset,
319                 dy: pos.y_offset,
320                 width: pos.x_advance,
321                 font,
322             });
323         }
324     }
325 
326     Some(glyphs)
327 }
328 
329 /// Outlines a glyph cluster.
330 ///
331 /// Uses one or more `Glyph`s to construct an `OutlinedCluster`.
outline_cluster( glyphs: &[Glyph], text: &str, font_size: f64, db: &fontdb::Database, ) -> OutlinedCluster332 fn outline_cluster(
333     glyphs: &[Glyph],
334     text: &str,
335     font_size: f64,
336     db: &fontdb::Database,
337 ) -> OutlinedCluster {
338     debug_assert!(!glyphs.is_empty());
339 
340     let mut path = tree::PathData::new();
341     let mut advance = 0.0;
342     let mut x = 0.0;
343 
344     for glyph in glyphs {
345         let mut outline = db.outline(glyph.font.id, glyph.id).unwrap_or_default();
346 
347         let sx = glyph.font.scale(font_size);
348 
349         if !outline.is_empty() {
350             // By default, glyphs are upside-down, so we have to mirror them.
351             let mut ts = tree::Transform::new_scale(1.0, -1.0);
352 
353             // Scale to font-size.
354             ts.scale(sx, sx);
355 
356             // Apply offset.
357             //
358             // The first glyph in the cluster will have an offset from 0x0,
359             // but the later one will have an offset from the "current position".
360             // So we have to keep an advance.
361             // TODO: should be done only inside a single text span
362             ts.translate(x + glyph.dx as f64, glyph.dy as f64);
363 
364             outline.transform(ts);
365 
366             path.extend_from_slice(&outline);
367         }
368 
369         x += glyph.width as f64;
370 
371         let glyph_width = glyph.width as f64 * sx;
372         if glyph_width > advance {
373             advance = glyph_width;
374         }
375     }
376 
377     let byte_idx = glyphs[0].byte_idx;
378     let font = glyphs[0].font;
379     OutlinedCluster {
380         byte_idx,
381         codepoint: byte_idx.char_from(text),
382         advance,
383         ascent: font.ascent(font_size),
384         descent: font.descent(font_size),
385         x_height: font.x_height(font_size),
386         has_relative_shift: false,
387         path,
388         transform: tree::Transform::default(),
389         visible: true,
390     }
391 }
392 
393 /// Finds a font with a specified char.
394 ///
395 /// This is a rudimentary font fallback algorithm.
find_font_for_char( c: char, exclude_fonts: &[fontdb::ID], state: &State, ) -> Option<fontdb::Font>396 fn find_font_for_char(
397     c: char,
398     exclude_fonts: &[fontdb::ID],
399     state: &State,
400 ) -> Option<fontdb::Font> {
401     let base_font_id = exclude_fonts[0];
402 
403     let db = state.db.borrow();
404 
405     // Iterate over fonts and check if any of them support the specified char.
406     for item in db.fonts() {
407         // Ignore fonts, that were used for shaping already.
408         if exclude_fonts.contains(&item.id) {
409             continue;
410         }
411 
412         if db.font(base_font_id).properties != item.properties {
413             continue;
414         }
415 
416         if !db.has_char(item.id, c) {
417             continue;
418         }
419 
420         warn!(
421             "Fallback from {} to {}.",
422             db.font(base_font_id).path.display(),
423             item.path.display(),
424         );
425         return db.load_font(item.id);
426     }
427 
428     None
429 }
430 
431 /// Resolves clusters positions.
432 ///
433 /// Mainly sets the `transform` property.
434 ///
435 /// Returns the last text position. The next text chunk should start from that position.
resolve_clusters_positions( chunk: &TextChunk, char_offset: usize, pos_list: &[CharacterPosition], rotate_list: &[f64], writing_mode: WritingMode, clusters: &mut [OutlinedCluster], ) -> (f64, f64)436 pub fn resolve_clusters_positions(
437     chunk: &TextChunk,
438     char_offset: usize,
439     pos_list: &[CharacterPosition],
440     rotate_list: &[f64],
441     writing_mode: WritingMode,
442     clusters: &mut [OutlinedCluster],
443 ) -> (f64, f64) {
444     match chunk.text_flow {
445         TextFlow::Horizontal => {
446             resolve_clusters_positions_horizontal(
447                 chunk, char_offset, pos_list, rotate_list, clusters,
448             )
449         }
450         TextFlow::Path(ref path) => {
451             resolve_clusters_positions_path(
452                 chunk, char_offset, path, pos_list, rotate_list, writing_mode, clusters,
453             )
454         }
455     }
456 }
457 
resolve_clusters_positions_horizontal( chunk: &TextChunk, offset: usize, pos_list: &[CharacterPosition], rotate_list: &[f64], clusters: &mut [OutlinedCluster], ) -> (f64, f64)458 fn resolve_clusters_positions_horizontal(
459     chunk: &TextChunk,
460     offset: usize,
461     pos_list: &[CharacterPosition],
462     rotate_list: &[f64],
463     clusters: &mut [OutlinedCluster],
464 ) -> (f64, f64) {
465     let mut x = process_anchor(chunk.anchor, clusters_length(clusters));
466     let mut y = 0.0;
467 
468     for cluster in clusters {
469         let cp = offset + cluster.byte_idx.code_point_at(&chunk.text);
470         if let Some(pos) = pos_list.get(cp) {
471             x += pos.dx.unwrap_or(0.0);
472             y += pos.dy.unwrap_or(0.0);
473             cluster.has_relative_shift = pos.dx.is_some() || pos.dy.is_some();
474         }
475 
476         cluster.transform.translate(x, y);
477 
478         if let Some(angle) = rotate_list.get(cp).cloned() {
479             if !angle.is_fuzzy_zero() {
480                 cluster.transform.rotate(angle);
481                 cluster.has_relative_shift = true;
482             }
483         }
484 
485         x += cluster.advance;
486     }
487 
488     (x, y)
489 }
490 
resolve_clusters_positions_path( chunk: &TextChunk, char_offset: usize, path: &TextPath, pos_list: &[CharacterPosition], rotate_list: &[f64], writing_mode: WritingMode, clusters: &mut [OutlinedCluster], ) -> (f64, f64)491 fn resolve_clusters_positions_path(
492     chunk: &TextChunk,
493     char_offset: usize,
494     path: &TextPath,
495     pos_list: &[CharacterPosition],
496     rotate_list: &[f64],
497     writing_mode: WritingMode,
498     clusters: &mut [OutlinedCluster],
499 ) -> (f64, f64) {
500     let mut last_x = 0.0;
501     let mut last_y = 0.0;
502 
503     let mut dy = 0.0;
504 
505     // In the text path mode, chunk's x/y coordinates provide an additional offset along the path.
506     // The X coordinate is used in a horizontal mode, and Y in vertical.
507     let chunk_offset = match writing_mode {
508         WritingMode::LeftToRight => chunk.x.unwrap_or(0.0),
509         WritingMode::TopToBottom => chunk.y.unwrap_or(0.0),
510     };
511 
512     let start_offset = chunk_offset + path.start_offset
513         + process_anchor(chunk.anchor, clusters_length(clusters));
514 
515     let normals = collect_normals(
516         chunk, clusters, &path.path, pos_list, char_offset, start_offset,
517     );
518     for (cluster, normal) in clusters.iter_mut().zip(normals) {
519         let (x, y, angle) = match normal {
520             Some(normal) => {
521                 (normal.x, normal.y, normal.angle)
522             }
523             None => {
524                 // Hide clusters that are outside the text path.
525                 cluster.visible = false;
526                 continue;
527             }
528         };
529 
530         // We have to break a decoration line for each cluster during text-on-path.
531         cluster.has_relative_shift = true;
532 
533         // Clusters should be rotated by the x-midpoint x baseline position.
534         let half_advance = cluster.advance / 2.0;
535         cluster.transform.translate(x - half_advance, y);
536         cluster.transform.rotate_at(angle, half_advance, 0.0);
537 
538         let cp = char_offset + cluster.byte_idx.code_point_at(&chunk.text);
539         if let Some(pos) = pos_list.get(cp) {
540             dy += pos.dy.unwrap_or(0.0);
541         }
542 
543         let baseline_shift = chunk.span_at(cluster.byte_idx)
544             .map(|span| span.baseline_shift)
545             .unwrap_or(0.0);
546 
547         // Shift only by `dy` since we already applied `dx`
548         // during offset along the path calculation.
549         if !dy.is_fuzzy_zero() || !baseline_shift.is_fuzzy_zero() {
550             let shift = kurbo::Vec2::from_angle(angle) + kurbo::Vec2::new(0.0, dy - baseline_shift);
551             cluster.transform.translate(shift.x, shift.y);
552         }
553 
554         if let Some(angle) = rotate_list.get(cp).cloned() {
555             if !angle.is_fuzzy_zero() {
556                 cluster.transform.rotate(angle);
557             }
558         }
559 
560         last_x = x + cluster.advance;
561         last_y = y;
562     }
563 
564     (last_x, last_y)
565 }
566 
clusters_length(clusters: &[OutlinedCluster]) -> f64567 fn clusters_length(clusters: &[OutlinedCluster]) -> f64 {
568     clusters.iter().fold(0.0, |w, cluster| w + cluster.advance)
569 }
570 
process_anchor( a: TextAnchor, text_width: f64, ) -> f64571 fn process_anchor(
572     a: TextAnchor,
573     text_width: f64,
574 ) -> f64 {
575     match a {
576         TextAnchor::Start   => 0.0, // Nothing.
577         TextAnchor::Middle  => -text_width / 2.0,
578         TextAnchor::End     => -text_width,
579     }
580 }
581 
582 struct PathNormal {
583     x: f64,
584     y: f64,
585     angle: f64,
586 }
587 
collect_normals( chunk: &TextChunk, clusters: &[OutlinedCluster], path: &tree::PathData, pos_list: &[CharacterPosition], char_offset: usize, offset: f64, ) -> Vec<Option<PathNormal>>588 fn collect_normals(
589     chunk: &TextChunk,
590     clusters: &[OutlinedCluster],
591     path: &tree::PathData,
592     pos_list: &[CharacterPosition],
593     char_offset: usize,
594     offset: f64,
595 ) -> Vec<Option<PathNormal>> {
596     debug_assert!(!path.is_empty());
597 
598     let mut offsets = Vec::with_capacity(clusters.len());
599     let mut normals = Vec::with_capacity(clusters.len());
600     {
601         let mut advance = offset;
602         for cluster in clusters {
603             // Clusters should be rotated by the x-midpoint x baseline position.
604             let half_advance = cluster.advance / 2.0;
605 
606             // Include relative position.
607             let cp = char_offset + cluster.byte_idx.code_point_at(&chunk.text);
608             if let Some(pos) = pos_list.get(cp) {
609                 advance += pos.dx.unwrap_or(0.0);
610             }
611 
612             let offset = advance + half_advance;
613 
614             // Clusters outside the path have no normals.
615             if offset < 0.0 {
616                 normals.push(None);
617             }
618 
619             offsets.push(offset);
620             advance += cluster.advance;
621         }
622     }
623 
624     let (mut prev_mx, mut prev_my, mut prev_x, mut prev_y) = {
625         if let tree::PathSegment::MoveTo { x, y } = path[0] {
626             (x, y, x, y)
627         } else {
628             unreachable!();
629         }
630     };
631 
632     fn create_curve_from_line(px: f64, py: f64, x: f64, y: f64) -> kurbo::CubicBez {
633         let line = kurbo::Line::new(kurbo::Point::new(px, py), kurbo::Point::new(x, y));
634         let p1 = line.eval(0.33);
635         let p2 = line.eval(0.66);
636         kurbo::CubicBez::from_points(px, py, p1.x, p1.y, p2.x, p2.y, x, y)
637     }
638 
639     let mut length = 0.0;
640     for seg in path.iter() {
641         let curve = match *seg {
642             tree::PathSegment::MoveTo { x, y } => {
643                 prev_mx = x;
644                 prev_my = y;
645                 prev_x = x;
646                 prev_y = y;
647                 continue;
648             }
649             tree::PathSegment::LineTo { x, y } => {
650                 create_curve_from_line(prev_x, prev_y, x, y)
651             }
652             tree::PathSegment::CurveTo { x1, y1, x2, y2, x, y } => {
653                 kurbo::CubicBez::from_points(prev_x, prev_y, x1, y1, x2, y2, x, y)
654             }
655             tree::PathSegment::ClosePath => {
656                 create_curve_from_line(prev_x, prev_y, prev_mx, prev_my)
657             }
658         };
659 
660         let curve_len = curve.arclen(1.0);
661 
662         for offset in &offsets[normals.len()..] {
663             if *offset >= length && *offset <= length + curve_len {
664                 let offset = (offset - length) / curve_len;
665                 debug_assert!(offset >= 0.0 && offset <= 1.0);
666 
667                 let pos = curve.eval(offset);
668                 let d = curve.deriv().eval(offset);
669                 let d = kurbo::Vec2::new(-d.y, d.x); // tangent
670                 let angle = d.atan2().to_degrees() - 90.0;
671 
672                 normals.push(Some(PathNormal {
673                     x: pos.x,
674                     y: pos.y,
675                     angle,
676                 }));
677 
678                 if normals.len() == offsets.len() {
679                     break;
680                 }
681             }
682         }
683 
684         length += curve_len;
685         prev_x = curve.p3.x;
686         prev_y = curve.p3.y;
687     }
688 
689     // If path ended and we still have unresolved normals - set them to `None`.
690     for _ in 0..(offsets.len() - normals.len()) {
691         normals.push(None);
692     }
693 
694     normals
695 }
696 
697 /// Applies the `letter-spacing` property to a text chunk clusters.
698 ///
699 /// [In the CSS spec](https://www.w3.org/TR/css-text-3/#letter-spacing-property).
apply_letter_spacing( chunk: &TextChunk, clusters: &mut [OutlinedCluster], )700 pub fn apply_letter_spacing(
701     chunk: &TextChunk,
702     clusters: &mut [OutlinedCluster],
703 ) {
704     // At least one span should have a non-zero spacing.
705     if !chunk.spans.iter().any(|span| !span.letter_spacing.is_fuzzy_zero()) {
706         return;
707     }
708 
709     for cluster in clusters {
710         // Spacing must be applied only to characters that belongs to the script
711         // that supports spacing.
712         // We are checking only the first code point, since it should be enough.
713         let script = cluster.codepoint.script();
714         if script_supports_letter_spacing(script) {
715             if let Some(span) = chunk.span_at(cluster.byte_idx) {
716                 // Technically, we should ignore spacing on the last character,
717                 // but it doesn't affect us in any way, so we are ignoring this.
718                 cluster.advance += span.letter_spacing;
719 
720                 // If the cluster advance became negative - clear it.
721                 // This is an UB so we can do whatever we want, so we mimic the Chrome behavior.
722                 if !cluster.advance.is_valid_length() {
723                     cluster.advance = 0.0;
724                     cluster.path.clear();
725                 }
726             }
727         }
728     }
729 }
730 
731 /// Checks that selected script supports letter spacing.
732 ///
733 /// [In the CSS spec](https://www.w3.org/TR/css-text-3/#cursive-tracking).
734 ///
735 /// The list itself is from: https://github.com/harfbuzz/harfbuzz/issues/64
script_supports_letter_spacing(script: unicode_script::Script) -> bool736 fn script_supports_letter_spacing(script: unicode_script::Script) -> bool {
737     use unicode_script::Script;
738 
739     !matches!(script,
740           Script::Arabic
741         | Script::Syriac
742         | Script::Nko
743         | Script::Manichaean
744         | Script::Psalter_Pahlavi
745         | Script::Mandaic
746         | Script::Mongolian
747         | Script::Phags_Pa
748         | Script::Devanagari
749         | Script::Bengali
750         | Script::Gurmukhi
751         | Script::Modi
752         | Script::Sharada
753         | Script::Syloti_Nagri
754         | Script::Tirhuta
755         | Script::Ogham)
756 }
757 
758 /// Applies the `word-spacing` property to a text chunk clusters.
759 ///
760 /// [In the CSS spec](https://www.w3.org/TR/css-text-3/#propdef-word-spacing).
apply_word_spacing( chunk: &TextChunk, clusters: &mut [OutlinedCluster], )761 pub fn apply_word_spacing(
762     chunk: &TextChunk,
763     clusters: &mut [OutlinedCluster],
764 ) {
765     // At least one span should have a non-zero spacing.
766     if !chunk.spans.iter().any(|span| !span.word_spacing.is_fuzzy_zero()) {
767         return;
768     }
769 
770     for cluster in clusters {
771         if is_word_separator_characters(cluster.codepoint) {
772             if let Some(span) = chunk.span_at(cluster.byte_idx) {
773                 // Technically, word spacing 'should be applied half on each
774                 // side of the character', but it doesn't affect us in any way,
775                 // so we are ignoring this.
776                 cluster.advance += span.word_spacing;
777 
778                 // After word spacing, `advance` can be negative.
779             }
780         }
781     }
782 }
783 
784 /// Checks that the selected character is a word separator.
785 ///
786 /// According to: https://www.w3.org/TR/css-text-3/#word-separator
is_word_separator_characters(c: char) -> bool787 fn is_word_separator_characters(c: char) -> bool {
788     matches!(c as u32, 0x0020 | 0x00A0 | 0x1361 | 0x010100 | 0x010101 | 0x01039F | 0x01091F)
789 }
790 
791 /// Rotates clusters according to
792 /// [Unicode Vertical_Orientation Property](https://www.unicode.org/reports/tr50/tr50-19.html).
apply_writing_mode( writing_mode: WritingMode, clusters: &mut [OutlinedCluster], )793 pub fn apply_writing_mode(
794     writing_mode: WritingMode,
795     clusters: &mut [OutlinedCluster],
796 ) {
797     if writing_mode != WritingMode::TopToBottom {
798         return;
799     }
800 
801     for cluster in clusters {
802         let orientation = unicode_vo::char_orientation(cluster.codepoint);
803         if orientation == CharOrientation::Upright {
804             // Additional offset. Not sure why.
805             let dy = cluster.advance - cluster.height();
806 
807             // Rotate a cluster 90deg counter clockwise by the center.
808             let mut ts = tree::Transform::default();
809             ts.translate(cluster.advance / 2.0, 0.0);
810             ts.rotate(-90.0);
811             ts.translate(-cluster.advance / 2.0, -dy);
812             cluster.path.transform(ts);
813 
814             // Move "baseline" to the middle and make height equal to advance.
815             cluster.ascent = cluster.advance / 2.0;
816             cluster.descent = -cluster.advance / 2.0;
817         } else {
818             // Could not find a spec that explains this,
819             // but this is how other applications are shifting the "rotated" characters
820             // in the top-to-bottom mode.
821             cluster.transform.translate(0.0, cluster.x_height / 2.0);
822         }
823     }
824 }
825