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(¶graph, 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