Skip to main content

fleuron/lines/
shape.rs

1//! Text to glyphs: a span at a time, on the face the cascade named.
2
3use std::ops::Range;
4
5use crate::fonts::{Features, ShapedGlyph};
6
7use super::LineLayout;
8use super::flatten::{FlatParagraph, StyleSpan};
9use super::line::{ShapedRun, cut_runs};
10use super::paragraph::ParagraphStyle;
11
12impl LineLayout<'_> {
13    /// One string as shaped runs, set the way `style` asks for it.
14    /// The counterpart of `layout` for text that is not broken into
15    /// lines: page furniture, an ornament, an initial letter.
16    pub fn shape(&self, text: &str, style: &ParagraphStyle) -> Option<Vec<ShapedRun>> {
17        let upem = self.registry.metrics(style.font_id)?.units_per_em as f32;
18        let mut flat = FlatParagraph::new();
19        flat.push_styled(text, style, self.small_caps(style));
20        let shaped = self.shape_spans(&flat, style, upem);
21        Some(cut_runs(&flat, &shaped, 0, flat.text.len()))
22    }
23
24    /// Shapes each span. A glyph's cluster is an offset into the
25    /// span, which indexes the paragraph text once offset by the
26    /// span's start. A span set at another size measures in its own
27    /// font units, so `scale` takes it into the paragraph's.
28    ///
29    /// Tracking is added here, to the last glyph of every cluster, so
30    /// every pass downstream measures it without being told about it.
31    pub(super) fn shape_spans(
32        &self,
33        flat: &FlatParagraph,
34        style: &ParagraphStyle,
35        upem: f32,
36    ) -> Vec<ShapedSpan> {
37        flat.spans
38            .iter()
39            .map(|span| {
40                let mut glyphs = self
41                    .registry
42                    .shape_with(span.font_id, &flat.text[span.range.clone()], &span.features)
43                    .unwrap_or_default();
44                let track = self.tracking_units(span);
45                if track != 0 {
46                    for at in 0..glyphs.len() {
47                        let last = glyphs
48                            .get(at + 1)
49                            .is_none_or(|next| next.cluster != glyphs[at].cluster);
50                        if last {
51                            glyphs[at].x_advance =
52                                (glyphs[at].x_advance as i64 + track).max(0) as u32;
53                        }
54                    }
55                }
56                let scale = self.scale(span.font_id, span.size, style, upem);
57                ShapedSpan {
58                    range: span.range.clone(),
59                    scale,
60                    track,
61                    tracking: track as f32 * scale,
62                    glyphs,
63                }
64            })
65            .collect()
66    }
67
68    /// A span's tracking in its own font units.
69    fn tracking_units(&self, span: &StyleSpan) -> i64 {
70        if span.tracking == 0.0 || span.size <= 0.0 {
71            return 0;
72        }
73        let upem = self
74            .registry
75            .metrics(span.font_id)
76            .map(|m| m.units_per_em as f32)
77            .unwrap_or(1000.0);
78        (span.tracking / span.size * upem).round() as i64
79    }
80
81    /// What a span's own font units are worth in the paragraph's.
82    /// One em of a 6pt face is not one em of an 11pt one, and the
83    /// measure is written in the paragraph's.
84    fn scale(&self, font_id: u16, size: f32, style: &ParagraphStyle, upem: f32) -> f32 {
85        let span_upem = self
86            .registry
87            .metrics(font_id)
88            .map(|m| m.units_per_em as f32)
89            .unwrap_or(upem);
90        if span_upem <= 0.0 || style.size <= 0.0 {
91            return 1.0;
92        }
93        size / span_upem * upem / style.size
94    }
95
96    /// Draws the hyphen a break inside a word leaves behind.
97    ///
98    /// The break was charged for it when it was chosen, so it is
99    /// drawn in the same face the charge was read from: a hyphen
100    /// taken from one face and paid for out of another is a line
101    /// that measures one width and paints a different one.
102    pub(super) fn hyphenate(&self, runs: &mut Vec<ShapedRun>, style: &ParagraphStyle) {
103        let Some(id) = self.registry.char_glyph(style.font_id, '-') else {
104            return;
105        };
106        let advance = self
107            .registry
108            .advance_width(style.font_id, id)
109            .unwrap_or_default() as u32;
110        let ending = runs
111            .last()
112            .map(|run| run.text_start + run.text.len() as u32)
113            .unwrap_or_default();
114        // The hyphen takes the last run's colour, because colour
115        // costs no width, and the paragraph's face, because that is
116        // where its width was charged.
117        let color = runs.last().map_or(style.color, |run| run.color);
118        // The hyphen belongs to the word it broke, so a rule drawn
119        // across that word is drawn across it too.
120        let decoration = runs.last().map_or(style.decoration, |run| run.decoration);
121        // The hyphen belongs to the word it broke, so it sits inside
122        // whatever box that word is in.
123        let inline = runs.last().and_then(|run| run.inline);
124        match runs.last_mut() {
125            Some(run) if run.font_id == style.font_id && run.size == style.size => {
126                run.text.push('-');
127                // A hyphen the breaker drew stands for nothing the
128                // author wrote, so it maps to an empty stretch of the
129                // source and extraction reads straight past it.
130                if let Some(end) = run.source_map.last().copied() {
131                    run.source_map.push(end);
132                }
133                run.glyphs.push(ShapedGlyph {
134                    id,
135                    x_advance: advance,
136                    cluster: ending,
137                });
138                run.advance += advance;
139            }
140            // A break inside an emphasised word: the hyphen is the
141            // paragraph's own, so it goes in a run of its own rather
142            // than into a face it was not measured in.
143            _ => runs.push(ShapedRun {
144                font_id: style.font_id,
145                size: style.size,
146                text: "-".to_string(),
147                source: String::new(),
148                source_map: Vec::new(),
149                text_start: ending,
150                origin: None,
151                pseudo_element: None,
152                inline,
153                lead: 0.0,
154                trail: 0.0,
155                features: Features::NONE,
156                color,
157                decoration,
158                glyphs: vec![ShapedGlyph {
159                    id,
160                    x_advance: advance,
161                    cluster: ending,
162                }],
163                advance,
164            }),
165        }
166    }
167
168    pub(super) fn hyphen_advance(&self, style: &ParagraphStyle) -> u32 {
169        self.registry
170            .char_glyph(style.font_id, '-')
171            .and_then(|g| self.registry.advance_width(style.font_id, g))
172            .unwrap_or(0) as u32
173    }
174}
175
176/// One shaped span, its glyph clusters still relative to the span's
177/// own text.
178pub(super) struct ShapedSpan {
179    /// Byte range of the span in the paragraph text.
180    pub(super) range: Range<usize>,
181    /// What one of this span's font units is worth in the
182    /// paragraph's.
183    pub(super) scale: f32,
184    /// Tracking charged after each of its clusters, in its own font
185    /// units.
186    pub(super) track: i64,
187    /// The same in the paragraph's font units.
188    pub(super) tracking: f32,
189    pub(super) glyphs: Vec<ShapedGlyph>,
190}
191
192impl ShapedSpan {
193    /// The glyphs whose clusters fall in `[start, end)`, clusters
194    /// rebased to the paragraph text.
195    pub(super) fn glyphs_in(&self, start: usize, end: usize) -> Vec<ShapedGlyph> {
196        self.glyphs
197            .iter()
198            .filter(|g| {
199                let cluster = self.range.start + g.cluster as usize;
200                cluster >= start && cluster < end
201            })
202            .map(|g| ShapedGlyph {
203                cluster: g.cluster + self.range.start as u32,
204                ..*g
205            })
206            .collect()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use crate::content::{Attributes, Inline, NodeId};
213    use crate::lines::flatten::SMALL_CAPS_RATIO;
214    use crate::lines::testing::{
215        body, hyphenated, layout_body_opts, layout_style, line_text, registry, units_per_em,
216    };
217    use crate::lines::{LineBreakOptions, LineLayout, Measure, Opening, ParagraphStyle};
218    use crate::style::FontVariantCaps;
219
220    /// The hyphen is painted as well as charged: the run has the
221    /// character and a glyph for it, and the last line has neither.
222    #[test]
223    fn a_hyphenated_line_paints_the_hyphen_it_paid_for() {
224        let text = "extraordinarily";
225        let lines = layout_body_opts(text, 53.0, hyphenated());
226        let first = &lines[0];
227        assert_eq!(line_text(first), "extraordi-");
228
229        let run = first.runs.last().expect("the line has no runs");
230        assert_eq!(
231            run.glyphs.len(),
232            run.text.chars().count(),
233            "the hyphen is in the text and not in the glyphs",
234        );
235        let ranges = run.glyph_ranges();
236        let last = ranges.last().expect("the run has no glyphs");
237        assert_eq!(
238            &run.text[last.start as usize..last.end as usize],
239            "-",
240            "the last glyph does not stand for the hyphen",
241        );
242
243        // Charged and drawn are the same number: the line's width
244        // covers the glyph it ends with.
245        let hyphen = run.glyphs.last().expect("the run has no glyphs").x_advance;
246        assert!(hyphen > 0, "the hyphen has no advance");
247        assert_eq!(
248            first.width,
249            first.runs.iter().map(|r| r.advance).sum::<u32>(),
250            "the line's width and its runs disagree",
251        );
252
253        let last_line = lines.last().expect("no lines");
254        assert!(
255            !line_text(last_line).ends_with('-'),
256            "the last line was hyphenated: {:?}",
257            line_text(last_line),
258        );
259    }
260
261    /// Emphasis is its own span: a paragraph of roman prose around
262    /// italic dialogue breaks into runs at the markup's boundaries,
263    /// each on the face its style resolved to, and a nested `strong`
264    /// takes the bold italic cut.
265    #[test]
266    fn emphasis_shapes_on_its_own_face() {
267        let mut book = crate::content::Book {
268            metadata: Default::default(),
269            sections: vec![crate::content::Section {
270                attributes: Default::default(),
271                blocks: vec![crate::content::Block::Paragraph {
272                    id: NodeId::UNASSIGNED,
273                    inlines: vec![
274                        Inline::Text {
275                            id: NodeId::UNASSIGNED,
276                            value: "He said ".into(),
277                            attributes: Attributes::default(),
278                            position: None,
279                            span: None,
280                        },
281                        Inline::Emphasis {
282                            id: NodeId::UNASSIGNED,
283                            children: vec![
284                                Inline::Text {
285                                    id: NodeId::UNASSIGNED,
286                                    value: "never ".into(),
287                                    attributes: Attributes::default(),
288                                    position: None,
289                                    span: None,
290                                },
291                                Inline::Strong {
292                                    id: NodeId::UNASSIGNED,
293                                    children: vec![Inline::Text {
294                                        id: NodeId::UNASSIGNED,
295                                        value: "again".into(),
296                                        attributes: Attributes::default(),
297                                        position: None,
298                                        span: None,
299                                    }],
300                                    attributes: Attributes::default(),
301                                    position: None,
302                                    span: None,
303                                },
304                            ],
305                            attributes: Attributes::default(),
306                            position: None,
307                            span: None,
308                        },
309                        Inline::Text {
310                            id: NodeId::UNASSIGNED,
311                            value: " to her.".into(),
312                            attributes: Attributes::default(),
313                            position: None,
314                            span: None,
315                        },
316                    ],
317                    attributes: Attributes::default(),
318                    position: None,
319                    span: None,
320                }],
321                ..Default::default()
322            }],
323        };
324        book.assign_node_ids();
325        let styles = crate::style::defaults(&book, registry());
326        let crate::content::Block::Paragraph { id, inlines, .. } = &book.sections[0].blocks[0]
327        else {
328            unreachable!()
329        };
330        let lines = LineLayout::new(registry()).layout_styled(
331            inlines,
332            &styles.paragraph(*id),
333            &styles,
334            &Measure::uniform(400.0),
335            Default::default(),
336            Opening::default(),
337        );
338        assert_eq!(lines.len(), 1);
339        let runs: Vec<(u16, &str)> = lines[0]
340            .runs
341            .iter()
342            .map(|run| (run.font_id, run.text.as_str()))
343            .collect();
344        let face = |italic, weight| {
345            registry()
346                .select(
347                    "eb garamond",
348                    crate::fonts::FaceAttributes { italic, weight },
349                )
350                .unwrap()
351                .id
352        };
353        assert_eq!(
354            runs,
355            vec![
356                (face(false, 400), "He said "),
357                (face(true, 400), "never "),
358                (face(true, 700), "again"),
359                (face(false, 400), " to her."),
360            ],
361        );
362    }
363
364    /// Letter-spacing is advance on the shaper's glyphs: every gap
365    /// between two glyphs opens by the tracking, and the last glyph
366    /// keeps its own advance, so a tracked title measures the tracking
367    /// times one fewer than its glyphs.
368    #[test]
369    fn letter_spacing_opens_the_gaps_between_glyphs() {
370        let plain = layout_style("HANDGLOVES", 400.0, &body());
371        let tracking = 0.08 * body().size;
372        let tracked = layout_style(
373            "HANDGLOVES",
374            400.0,
375            &ParagraphStyle {
376                letter_spacing: tracking,
377                ..body()
378            },
379        );
380        let glyphs = plain[0].runs[0].glyphs.len();
381        assert_eq!(glyphs, 10, "the title shaped to one glyph a letter");
382        let units = tracking / body().size * units_per_em() as f32;
383        assert_eq!(
384            tracked[0].width - plain[0].width,
385            (units * (glyphs - 1) as f32).round() as u32,
386            "tracking did not open exactly the gaps between the glyphs",
387        );
388        for (loose, tight) in tracked[0].runs[0]
389            .glyphs
390            .iter()
391            .zip(&plain[0].runs[0].glyphs)
392            .take(glyphs - 1)
393        {
394            assert_eq!(
395                loose.x_advance - tight.x_advance,
396                units.round() as u32,
397                "a glyph has no tracking of its own",
398            );
399        }
400    }
401
402    /// Small capitals come out of the face where the face has them:
403    /// the run stays at the size around it and draws glyphs the plain
404    /// text does not. A face with no substitutions of its own gets a
405    /// synthesis instead, the letters raised to capitals and set at a
406    /// fraction of the size, and the capitals already there are left
407    /// alone.
408    #[test]
409    fn small_caps_take_the_feature_or_a_synthesis() {
410        let style = ParagraphStyle {
411            caps: FontVariantCaps::SmallCaps,
412            ..body()
413        };
414        let plain = layout_style("hello", 400.0, &body());
415        let feature = layout_style("hello", 400.0, &style);
416        assert_eq!(feature[0].runs.len(), 1, "the feature split the run");
417        assert_eq!(feature[0].runs[0].size, body().size);
418        assert_ne!(
419            feature[0].runs[0]
420                .glyphs
421                .iter()
422                .map(|g| g.id)
423                .collect::<Vec<_>>(),
424            plain[0].runs[0]
425                .glyphs
426                .iter()
427                .map(|g| g.id)
428                .collect::<Vec<_>>(),
429            "the face's small capitals drew the lowercase glyphs",
430        );
431
432        let bare = crate::fonts::registry_without_substitutions();
433        assert!(!bare.has_small_caps(0), "the face still substitutes");
434        let inlines = vec![Inline::Text {
435            id: NodeId::UNASSIGNED,
436            value: "hi Ho".to_string(),
437            attributes: Attributes::default(),
438            position: None,
439            span: None,
440        }];
441        let synthesized =
442            LineLayout::new(&bare).layout(&inlines, &style, 400.0, LineBreakOptions::default());
443        let runs: Vec<(f32, &str, &str)> = synthesized[0]
444            .runs
445            .iter()
446            .map(|run| (run.size, run.text.as_str(), run.source.as_str()))
447            .collect();
448        assert_eq!(
449            runs,
450            vec![
451                (body().size * SMALL_CAPS_RATIO, "HI", "hi"),
452                (body().size, " H", ""),
453                (body().size * SMALL_CAPS_RATIO, "O", "o"),
454            ],
455            "the synthesis did not raise what was lowercase, leave the rest, \
456             and keep what the author wrote",
457        );
458    }
459}