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}