Skip to main content

fleuron/lines/
paragraph.rs

1//! What a paragraph is set in: the style over its runs, the style
2//! over the line it opens on, and what it hangs into the margin.
3
4use crate::content::{Inline, Metadata, NodeId};
5use crate::fonts::{FaceAttributes, FeatureSetting, FontRegistry};
6use crate::style::{Color, Edges, FontVariantCaps, TextDecoration, TextTransform};
7use serde::Serialize;
8
9/// Everything one paragraph's layout depends on, and the colour its
10/// runs are painted in. The style tree compiles down to this.
11#[derive(Debug, Clone)]
12pub struct ParagraphStyle {
13    /// Face id from the font registry.
14    pub font_id: u16,
15    /// Font size in points.
16    pub size: f32,
17    /// Line height as a unitless multiple of `size`, as in CSS
18    /// `line-height: <number>`.
19    pub line_height: f32,
20    /// Extra advance between glyphs, in points, from
21    /// `letter-spacing`. A line ends at its last glyph's own edge, so
22    /// nothing is added after it.
23    pub letter_spacing: f32,
24    /// Which capitals the text is drawn with.
25    pub caps: FontVariantCaps,
26    /// The features the text is shaped with, beyond the ones the
27    /// shaper turns on for every run.
28    pub features: Vec<FeatureSetting>,
29    /// What the text is transformed to before it is shaped.
30    pub transform: TextTransform,
31    /// What the run is painted in. Nothing measures it: a run
32    /// carries it from here to the display structure.
33    pub color: Color,
34    /// What is drawn across the run. Nothing measures it either: the
35    /// rules are painted over the advance the glyphs already took.
36    pub decoration: TextDecoration,
37}
38
39/// What `::first-line` changes about the paragraph it opens.
40///
41/// A field is set where the pseudo-element's style differs from the
42/// element's own, so what it changes reaches an emphasis or a link on
43/// the opening line without replacing the rest of their style.
44#[derive(Debug, Clone, Copy, Default, PartialEq)]
45pub struct FirstLine {
46    /// The cut the opening line is set in, where the rule asked for
47    /// another one.
48    pub face: Option<Face>,
49    /// `font-size`, in points.
50    pub size: Option<f32>,
51    /// `letter-spacing`, in points.
52    pub letter_spacing: Option<f32>,
53    /// `font-variant-caps`.
54    pub caps: Option<FontVariantCaps>,
55    /// `text-transform`.
56    pub transform: Option<TextTransform>,
57    /// `color`.
58    pub color: Option<Color>,
59    /// What `text-decoration` draws across the line.
60    pub decoration: Option<TextDecoration>,
61}
62
63/// The face `::first-line` asks its runs for: a family, a slope and
64/// a weight, each one set only where the rule set it.
65///
66/// A run of the opening line keeps what the rule left alone, so
67/// `font-style: italic` over a bold emphasis picks the bold italic
68/// rather than the regular one. The family is a face of the family
69/// asked for, because a family is named once in the style tree and
70/// carried here by one of its cuts.
71#[derive(Debug, Clone, Copy, Default, PartialEq)]
72pub struct Face {
73    /// A face of the family the line is set in.
74    pub family: Option<u16>,
75    /// Whether the line is italic.
76    pub italic: Option<bool>,
77    /// Weight on the CSS 1 to 1000 scale.
78    pub weight: Option<u16>,
79}
80
81impl FirstLine {
82    /// The style one run of the opening line is set in, with `face`
83    /// matched against `registry`.
84    pub fn over(&self, style: &ParagraphStyle, registry: &FontRegistry) -> ParagraphStyle {
85        ParagraphStyle {
86            font_id: self.font_id(style, registry),
87            size: self.size.unwrap_or(style.size),
88            letter_spacing: self.letter_spacing.unwrap_or(style.letter_spacing),
89            caps: self.caps.unwrap_or(style.caps),
90            transform: self.transform.unwrap_or(style.transform),
91            color: self.color.unwrap_or(style.color),
92            decoration: self.decoration.unwrap_or(style.decoration),
93            ..style.clone()
94        }
95    }
96
97    /// The face one run of the opening line is shaped with: the run's
98    /// own, where the rule asked for no other cut, and otherwise the
99    /// closest cut of the family asked for.
100    ///
101    /// A family with nothing at the slope or weight asked for answers
102    /// with what it has, which is CSS font matching. The run keeps
103    /// its own face where the family answers with nothing at all.
104    fn font_id(&self, style: &ParagraphStyle, registry: &FontRegistry) -> u16 {
105        let Some(face) = self.face else {
106            return style.font_id;
107        };
108        let Some(own) = registry.font_ref(style.font_id) else {
109            return style.font_id;
110        };
111        let want = FaceAttributes {
112            italic: face.italic.unwrap_or(own.attributes.italic),
113            weight: face.weight.unwrap_or(own.attributes.weight),
114        };
115        let family = face
116            .family
117            .and_then(|id| registry.font_ref(id))
118            .unwrap_or(own);
119        registry
120            .select(&family.family, want)
121            .map_or(style.font_id, |found| found.id)
122    }
123}
124
125/// What sets a paragraph's opening apart from the rest of it.
126#[derive(Debug, Clone, Copy, Default, PartialEq)]
127pub struct Opening {
128    /// What `::first-line` changes about the line the paragraph
129    /// opens on.
130    pub first_line: Option<FirstLine>,
131    /// Bytes of the paragraph's text a drop cap already set. They
132    /// are neither shaped nor broken here; the cap carries the range
133    /// they cover.
134    pub taken: usize,
135    /// The paragraph. The runs its `::first-line` style reaches carry
136    /// the id of that pseudo-element.
137    pub node: NodeId,
138}
139
140/// What one pass of the breaker is told about the paragraph's
141/// opening: the style it is set in, how far that reaches, and what a
142/// drop cap already took off the front of it.
143#[derive(Debug, Clone, Copy, Default)]
144pub(super) struct Lead {
145    pub(super) style: Option<FirstLine>,
146    /// Bytes of the shaped text the style covers. `None` on the pass
147    /// that has no break to read it off yet, where it covers the
148    /// paragraph.
149    pub(super) extent: Option<usize>,
150    /// Bytes of the source the paragraph starts past.
151    pub(super) taken: usize,
152    /// The id of the `::first-line` the style comes from.
153    pub(super) pseudo_element: Option<NodeId>,
154}
155
156impl Lead {
157    /// How far into the shaped text the opening style reaches.
158    pub(super) fn reach(self) -> usize {
159        self.extent.unwrap_or(usize::MAX)
160    }
161}
162
163/// What one inline element paints around its runs, and what that
164/// takes on each edge.
165///
166/// The edges are points. The leading and trailing ones are width
167/// like any other, so the breaker is told about them; the ones above
168/// and below leave the line the height it was.
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub struct InlineBox {
171    /// Padding, in points.
172    pub padding: Edges,
173    /// Border widths, in points, zero on an edge that is not drawn.
174    pub border: Edges,
175    /// Whether `box-decoration-break: clone` closes the edges a line
176    /// break cuts.
177    pub cloned: bool,
178}
179
180impl InlineBox {
181    /// What the box takes before the first of its runs.
182    pub fn leading(&self) -> f32 {
183        self.padding.left + self.border.left
184    }
185
186    /// What it takes after the last of them.
187    pub fn trailing(&self) -> f32 {
188        self.padding.right + self.border.right
189    }
190
191    /// How far it reaches over the runs it covers.
192    pub fn above(&self) -> f32 {
193        self.padding.top + self.border.top
194    }
195
196    /// How far it reaches under them.
197    pub fn below(&self) -> f32 {
198        self.padding.bottom + self.border.bottom
199    }
200}
201
202/// Where line layout gets the style of one inline node.
203///
204/// The style tree answers by node id. `Inherited` answers with the
205/// block's own style, which is what a run of uniform text needs and
206/// what a caller with no tree in hand can supply.
207pub trait InlineStyles {
208    /// The style of `id`, given the style of the block it sits in.
209    fn style(&self, id: NodeId, block: &ParagraphStyle) -> ParagraphStyle;
210
211    /// The box the inline element `id` paints around its runs, where
212    /// it paints one. Nothing, for a caller with no tree to ask.
213    fn inline_box(&self, _id: NodeId) -> Option<InlineBox> {
214        None
215    }
216
217    /// The text the sheet generates around one inline element.
218    /// Nothing, for a caller with no sheet to ask.
219    fn generated(&self, _inline: &Inline) -> Generated {
220        Generated::default()
221    }
222
223    /// The reference of one note, in the style it is set in: the
224    /// note's number as the cascade spells it. Nothing, for a caller
225    /// with no book to number the notes of.
226    fn call(&self, _note: &Inline) -> Option<(String, ParagraphStyle)> {
227        None
228    }
229}
230
231/// Text the sheet generates around one inline element: what
232/// `::before` and `::after` hold, each with the style it is set in.
233#[derive(Debug, Clone, Default)]
234pub struct Generated {
235    /// Set before the element's own text.
236    pub before: Option<(String, ParagraphStyle)>,
237    /// Set after it.
238    pub after: Option<(String, ParagraphStyle)>,
239}
240
241/// Every inline takes the style of the block around it.
242pub struct Inherited;
243
244impl InlineStyles for Inherited {
245    fn style(&self, _id: NodeId, block: &ParagraphStyle) -> ParagraphStyle {
246        block.clone()
247    }
248}
249
250/// How a paragraph is broken and filled. The defaults are ragged
251/// right, no hyphenation, and no mark hanging past the measure.
252#[derive(Debug, Clone, Copy, Default, PartialEq)]
253pub struct LineBreakOptions {
254    /// Whether `hyphens: auto` is in force.
255    pub hyphenate: bool,
256    /// Which language's syllables `hyphenate` breaks at.
257    pub patterns: Patterns,
258    /// Whether the lines fill the measure, from
259    /// `text-align: justify`. Left, right and centred text all break
260    /// the same way; where the line then sits is the caller's.
261    pub justify: bool,
262    /// Whether justification also opens the space between letters,
263    /// from `text-justify: inter-character`.
264    pub inter_character: bool,
265    /// Which marks hang past the measure.
266    pub hanging: HangingPunctuation,
267    /// Whether the text is preformatted: it breaks at its own
268    /// newlines and nowhere else, and its spaces stand where they
269    /// were written. A code block is set this way.
270    pub preformatted: bool,
271}
272
273/// Which marks may hang past the measure, from
274/// `hanging-punctuation`.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
276pub struct HangingPunctuation {
277    /// `first`: opening punctuation hangs into the margin the line
278    /// starts at.
279    pub first: bool,
280    /// `allow-end` or `force-end`.
281    pub end: HangEnd,
282    /// `last`: the mark a paragraph ends on hangs past the measure.
283    pub last: bool,
284}
285
286impl HangingPunctuation {
287    /// Nothing hangs.
288    pub const NONE: HangingPunctuation = HangingPunctuation {
289        first: false,
290        end: HangEnd::None,
291        last: false,
292    };
293}
294
295/// How far `hanging-punctuation` goes at the end of a line.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
297#[serde(rename_all = "snake_case")]
298pub enum HangEnd {
299    /// Nothing hangs at a line end.
300    #[default]
301    None,
302    /// `allow-end`: a mark hangs only where hanging is what makes the
303    /// line fit.
304    Allow,
305    /// `force-end`: a mark at a line end always hangs.
306    Force,
307}
308
309/// How much of a mark hangs past the measure it ends a line at, as a
310/// fraction of its advance. The lighter the mark, the further it
311/// goes: a full stop leaves a hole in the margin that the eye reads
312/// as a ragged edge, a colon does not.
313pub(super) fn hang_end(mark: char) -> f32 {
314    match mark {
315        '.' | ',' => 0.7,
316        '-' | '\u{2010}' | '\u{2013}' => 0.5,
317        '"' | '\'' | '\u{201d}' | '\u{2019}' | '\u{00bb}' => 0.4,
318        '\u{2014}' => 0.25,
319        ';' | ':' | '!' | '?' => 0.2,
320        _ => 0.0,
321    }
322}
323
324/// The same for the mark a line opens with, hanging back into the
325/// margin the line starts at.
326pub(super) fn hang_start(mark: char) -> f32 {
327    match mark {
328        '"' | '\'' | '\u{201c}' | '\u{2018}' | '\u{00ab}' => 0.4,
329        '(' | '[' | '\u{2013}' | '\u{2014}' => 0.25,
330        _ => 0.0,
331    }
332}
333
334/// The syllable patterns `hyphens: auto` breaks words by.
335///
336/// The book's declared language chooses them; a book that declares
337/// none breaks by English.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
339pub struct Patterns(pub(super) Option<hypher::Lang>);
340
341impl Default for Patterns {
342    fn default() -> Patterns {
343        Patterns::ENGLISH
344    }
345}
346
347impl Patterns {
348    /// No patterns at all: every word stays whole.
349    pub const NONE: Patterns = Patterns(None);
350
351    /// The English patterns, which a book that declares no language
352    /// breaks by.
353    pub const ENGLISH: Patterns = Patterns(Some(hypher::Lang::English));
354
355    /// The patterns a book breaks by: the ones its declared
356    /// language names, English where it declares none, and `NONE`
357    /// where there are no patterns for the language it declares.
358    pub fn of(metadata: &Metadata) -> Patterns {
359        match metadata.language() {
360            Some(tag) => Patterns::of_tag(tag).unwrap_or(Patterns::NONE),
361            None => Patterns::default(),
362        }
363    }
364
365    /// The patterns a BCP 47 tag names, read from its primary
366    /// subtag, so `fr-CA` is French. `None` where there are no
367    /// patterns for that language.
368    pub fn of_tag(tag: &str) -> Option<Patterns> {
369        let primary = tag.split(['-', '_']).next().unwrap_or_default();
370        let code: [u8; 2] = primary.as_bytes().try_into().ok()?;
371        hypher::Lang::from_iso(code.map(|byte| byte.to_ascii_lowercase()))
372            .map(|lang| Patterns(Some(lang)))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::lines::testing::{
380        body, hyphenated, layout_body, layout_body_opts, line_text, registry, units_per_em,
381    };
382    use crate::lines::{LineBreakOptions, Patterns};
383
384    /// A BCP 47 tag is read by its primary subtag, whatever the
385    /// case and whatever follows it. A tag with no patterns behind it
386    /// resolves to nothing rather than to English.
387    #[test]
388    fn patterns_come_from_the_primary_subtag() {
389        let french = Patterns::of_tag("fr");
390        assert!(french.is_some());
391        assert_eq!(Patterns::of_tag("fr-CA"), french);
392        assert_eq!(Patterns::of_tag("FR_ca"), french);
393        assert_ne!(Patterns::of_tag("de"), french);
394
395        assert_eq!(Patterns::of_tag("xx"), None);
396        assert_eq!(Patterns::of_tag("haw"), None);
397        assert_eq!(Patterns::of_tag(""), None);
398    }
399
400    /// A book that declares nothing breaks by English, and one that
401    /// declares a language with no patterns breaks nowhere.
402    #[test]
403    fn a_book_without_a_language_breaks_by_english() {
404        let declared = |tag: &str| {
405            Patterns::of(&Metadata {
406                extra: [("language".to_string(), tag.to_string())]
407                    .into_iter()
408                    .collect(),
409                ..Default::default()
410            })
411        };
412        assert_eq!(Patterns::of(&Metadata::default()), Patterns::default());
413        assert_eq!(declared("en"), Patterns::default());
414        assert_eq!(declared("  "), Patterns::default());
415        assert_eq!(declared("xx"), Patterns::NONE);
416    }
417
418    /// Without patterns nothing breaks inside a word: the same lines
419    /// hyphenation off gives.
420    #[test]
421    fn no_patterns_leaves_every_word_whole() {
422        let text = "extraordinarily inconsiderate";
423        let whole = LineBreakOptions {
424            patterns: Patterns::NONE,
425            ..hyphenated()
426        };
427        assert_eq!(
428            layout_body_opts(text, 44.0, whole)
429                .iter()
430                .map(line_text)
431                .collect::<Vec<_>>(),
432            layout_body(text, 44.0)
433                .iter()
434                .map(line_text)
435                .collect::<Vec<_>>(),
436        );
437    }
438
439    /// Hanging punctuation: a mark at a line end is not charged to
440    /// the measure, so a word that would not otherwise fit does.
441    #[test]
442    fn a_mark_at_a_line_end_hangs_past_the_measure() {
443        // `My father had a small estate,` is 117.74pt: over a 116pt
444        // measure by less than the comma hangs.
445        let text = "My father had a small estate, and I was the third of five sons.";
446        let hanging = LineBreakOptions {
447            hanging: HangingPunctuation {
448                end: HangEnd::Force,
449                ..Default::default()
450            },
451            ..Default::default()
452        };
453        let flush = layout_body(text, 116.0);
454        let hung = layout_body_opts(text, 116.0, hanging);
455        assert_eq!(line_text(&flush[0]), "My father had a small");
456        assert_eq!(line_text(&hung[0]), "My father had a small estate,");
457        assert!(hung[0].overhang > 0.0, "the comma was still charged");
458    }
459
460    /// Margin kerning: a line opening on a quotation mark starts
461    /// before the measure does, by part of the mark's own width.
462    #[test]
463    fn an_opening_mark_hangs_into_the_margin() {
464        let text = "\"He said it would be so,\" and it was.";
465        let kerned = LineBreakOptions {
466            hanging: HangingPunctuation {
467                first: true,
468                ..Default::default()
469            },
470            ..Default::default()
471        };
472        let lines = layout_body_opts(text, 200.0, kerned);
473        assert_eq!(lines.len(), 1);
474        assert!(
475            lines[0].protrusion > 0.0,
476            "the quotation mark was not pulled out",
477        );
478        let quote = registry()
479            .advance_width(0, registry().char_glyph(0, '"').unwrap())
480            .unwrap() as f32
481            / units_per_em() as f32
482            * body().size;
483        assert!(
484            lines[0].protrusion < quote,
485            "the whole mark left the measure",
486        );
487    }
488}