leaf_core/style.rs
1//! A toolkit-neutral text style — the seam that lets one document model drive
2//! any frontend.
3//!
4//! The WYSIWYG builder ([`crate::wysiwyg`]) tags each rendered glyph with one of
5//! these instead of a `ratatui::Style` or a `gpui::TextStyle`, so the caret
6//! model and the AST→glyph layout stay free of any GUI/TUI dependency.
7//!
8//! What core records is *what a glyph is*, never *what color to paint it*: a
9//! [`Role`] (heading, code, link, a list bullet, …) plus the portable emphasis
10//! the author actually wrote (`**bold**`, `*em*`, `{+ins+}`, `{-del-}`). Palette
11//! is presentation, and presentation belongs to the frontend — a terminal tells
12//! a heading from body text by color because color is all it can vary, while a
13//! GUI varies size and font instead. So each frontend maps a [`Role`] to its own
14//! look: `leaf-tui` turns it into terminal colors, `leaf-gpui` into an `Hsla`
15//! plus a font size and family. Core stays out of that argument.
16//!
17//! The one place core does carry a paint value is the presentation vocabulary
18//! the author writes by hand: a `data-size` is a *name* ([`SizeStep`]) where a
19//! name will do and a measurement ([`FontSize::Points`]) where the author needed
20//! exactness, and the open type beside each closed enum — [`FontSize`],
21//! [`LineHeight`], [`TextColor`], [`FontFace`] — is where the two meet. A name
22//! outlives a theme change and a value does not, which is the trade the author
23//! makes knowingly; see `docs/proposals/exact-presentation-values.md`.
24
25use std::borrow::Cow;
26
27/// The value `key` carries in an attribute list, or `None` where the key is
28/// absent or bare (`<span data-size>`, which names nothing).
29///
30/// The one line every `from_attrs` in this module opens with. It takes the
31/// attribute list rather than the node so the module stays free of twig as well
32/// as of any toolkit; the pairs are plain `String`s, and the WYSIWYG builder,
33/// the source builder and the caret queries all hand over the same
34/// `node.attrs`.
35fn attr<'a>(attrs: &'a [(String, Option<String>)], key: &str) -> Option<&'a str> {
36 attrs
37 .iter()
38 .find(|(k, _)| k == key)
39 .and_then(|(_, v)| v.as_deref())
40}
41
42/// What a glyph *is*, typographically — the semantic role a frontend maps to its
43/// own presentation. Mutually exclusive per glyph (a glyph is a heading, or a
44/// link, or body text — not two at once); the compositional emphasis a run can
45/// also carry lives in [`Style`]'s `bold`/`italic`/`underline`/`strikethrough`
46/// flags alongside this.
47#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
48pub enum Role {
49 /// Ordinary prose — the surface's default text.
50 #[default]
51 Body,
52 /// A heading of the given level (1 = top). A GUI scales the font by level; a
53 /// terminal cycles a color by it.
54 Heading(u8),
55 /// Code — inline `` `verbatim` `` or a fenced block. A GUI renders it in a
56 /// monospace family; a terminal tints it.
57 Code,
58 /// A hyperlink's visible text (or bare URL/email).
59 Link,
60 /// Highlighted / marked text (`==mark==`), carrying the colour the author
61 /// named if they named one. `None` is a plain highlight — the only kind
62 /// there was before twig grew Obsidian's `==🔴 red==` spelling, and
63 /// still the only kind a format without the colour extension can produce.
64 Mark(Option<MarkColor>),
65 /// A list item's bullet or number — synthetic decoration, not authored text.
66 ListMarker,
67 /// A block quote's gutter (`│`), drawn down its left edge.
68 QuoteGutter,
69 /// A drawn rule: a thematic break (`───`) or a table's borders. A GUI that
70 /// draws its own tables ignores the border glyphs; the rule still reaches it.
71 Rule,
72 /// Raw markup a revealed line is showing: the `*` around an emphasis, the
73 /// `# ` opening a heading, a link's `](dest)`. Only ever emitted for the
74 /// caret's line under [`MarkupMode::Full`](crate::MarkupMode::Full) —
75 /// every other line resolves its markup away and has none of these.
76 ///
77 /// A role rather than a `Style` flag because it is what the glyph *is*: the
78 /// delimiter of an emphasis is not itself emphasised text. A frontend
79 /// typically dims it, so the revealed line still reads as prose with its
80 /// scaffolding visible rather than as source code. One that doesn't map it
81 /// draws it as body text, which is correct if unsubtle.
82 Delimiter,
83 /// A block-level image's placeholder text (`🖼 alt`). The glyphs are a
84 /// *default* rendering any surface can paint as-is (a terminal shows the
85 /// label); an image-capable frontend skips the placeholder row named by the
86 /// map's [`MediaInfo`](crate::wysiwyg::MediaInfo) `rows_span` and paints the
87 /// real picture in its place — the same skip-the-picture contract
88 /// [`Role::Rule`] table borders use.
89 Image,
90 /// A formula standing in for its picture: the single atom glyph an inline
91 /// `$…$` renders to on a surface that paints pictures in a line, and the
92 /// `∑ tex` placeholder label a display `$$…$$` block renders to on every
93 /// surface. Both are *default* renderings a plain surface paints as-is;
94 /// a picture-capable frontend reads the map's
95 /// [`MathInfo`](crate::wysiwyg::MathInfo) side-table, typesets the TeX
96 /// it names, and draws the picture in the glyph's or the rows' place —
97 /// the [`Role::Image`] contract, one glyph narrower.
98 ///
99 /// Never the formula's *source*: on the caret's line a formula reveals to
100 /// its TeX in [`Role::Code`], with its delimiters in [`Role::Delimiter`],
101 /// in every markup mode — because a formula's content is not its picture,
102 /// and hiding the delimiters alone would leave nothing to edit.
103 Math,
104}
105
106/// The colour an author named on a highlight — the closed vocabulary twig
107/// records as a `mark` node's `data-color`, one variant per circle emoji the
108/// `==🔴 text==` spelling recognises.
109///
110/// A *name*, not a paint value, which is why this lives in core at all when
111/// [`Style`] otherwise holds no colour: `red` here is what the author wrote,
112/// and each frontend still decides which red draws it — a terminal picks an
113/// ANSI hue, a GUI an `Hsla`, the web a CSS custom property. The distinction is
114/// the same one [`Role::Heading`] makes by carrying a level rather than a size.
115///
116/// These seven stay the whole of a *highlight's* vocabulary, because they are
117/// encoded in twig's Markdown bytes as circle emoji and are twig's to keep
118/// closed. A run's foreground has no such constraint and opens: see
119/// [`TextColor`], which is one of these names or a hex triple.
120#[derive(Clone, Copy, PartialEq, Eq, Debug)]
121pub enum MarkColor {
122 Red,
123 Orange,
124 Yellow,
125 Green,
126 Blue,
127 Purple,
128 Brown,
129}
130
131impl MarkColor {
132 /// The colour a `mark` node's attributes name, if any — twig records it
133 /// under `data-color`, having stripped the emoji that spelled it out of the
134 /// node's content.
135 ///
136 /// Reads the list through [`attr`], which is why the pairs are plain
137 /// `String`s rather than anything of twig's.
138 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
139 attr(attrs, "data-color").and_then(Self::from_attr)
140 }
141
142 /// Read a `data-color` attribute value. `None` for a name outside the
143 /// vocabulary, which a frontend then draws as a plain highlight rather than
144 /// guessing at a hue.
145 pub fn from_attr(value: &str) -> Option<Self> {
146 Some(match value {
147 "red" => Self::Red,
148 "orange" => Self::Orange,
149 "yellow" => Self::Yellow,
150 "green" => Self::Green,
151 "blue" => Self::Blue,
152 "purple" => Self::Purple,
153 "brown" => Self::Brown,
154 _ => return None,
155 })
156 }
157
158 /// The name twig spells it with, and what [`from_attr`](Self::from_attr)
159 /// reads back — also the suffix the web and Swift frontends build a class
160 /// id out of.
161 pub const fn name(self) -> &'static str {
162 match self {
163 Self::Red => "red",
164 Self::Orange => "orange",
165 Self::Yellow => "yellow",
166 Self::Green => "green",
167 Self::Blue => "blue",
168 Self::Purple => "purple",
169 Self::Brown => "brown",
170 }
171 }
172
173 /// This colour's position in [`ALL`](Self::ALL) — the index a frontend's
174 /// own palette array is keyed by, the way [`Role::Heading`]'s level keys a
175 /// heading ramp.
176 pub const fn index(self) -> usize {
177 match self {
178 Self::Red => 0,
179 Self::Orange => 1,
180 Self::Yellow => 2,
181 Self::Green => 3,
182 Self::Blue => 4,
183 Self::Purple => 5,
184 Self::Brown => 6,
185 }
186 }
187
188 /// Every colour, in the order twig's own enum declares them. The frontends
189 /// iterate this to build their palettes, so a colour added here is one a
190 /// palette test immediately demands an entry for.
191 pub const ALL: [Self; 7] = [
192 Self::Red,
193 Self::Orange,
194 Self::Yellow,
195 Self::Green,
196 Self::Blue,
197 Self::Purple,
198 Self::Brown,
199 ];
200}
201
202/// How a block's lines are set across the measure — the one presentation
203/// property an author reaches for before any other, and the only one of the
204/// six whose vocabulary is a *class* rather than a `data-` key.
205///
206/// A `class` is a space-separated token list in every format leaf opens, and
207/// this reads the one token it knows and leaves the rest: a paragraph that
208/// arrives as `class="lead center"` is centred and keeps `lead`. A token
209/// outside the vocabulary is not an error — it is somebody else's class, and a
210/// document from elsewhere passes through the editor unharmed.
211///
212/// There is no `Left`, because absence is left: the default alignment is the
213/// theme's, and a document that agrees with it has no reason to say so. A
214/// right-to-left default is a theme matter, not a class.
215#[derive(Clone, Copy, PartialEq, Eq, Debug)]
216pub enum Align {
217 Center,
218 Right,
219 Justify,
220}
221
222impl Align {
223 /// The alignment a block's attributes name, if any — the first token of
224 /// `class` that is one of the three, in source order.
225 ///
226 /// Takes the attribute list rather than the node for [`MarkColor`]'s
227 /// reason: this module stays free of twig as well as of any toolkit, and
228 /// both the block walker and the caret query hand over the same
229 /// `node.attrs`.
230 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
231 attr(attrs, "class")?
232 .split_whitespace()
233 .find_map(Self::from_token)
234 }
235
236 /// Read one `class` token. `None` for a token outside the vocabulary,
237 /// which is how a foreign class is *kept* rather than misread — the
238 /// gesture that rewrites the alignment removes only the tokens this
239 /// answers `Some` for.
240 pub fn from_token(token: &str) -> Option<Self> {
241 Some(match token {
242 "center" => Self::Center,
243 "right" => Self::Right,
244 "justify" => Self::Justify,
245 _ => return None,
246 })
247 }
248
249 /// The token twig spells it with, and what [`from_token`](Self::from_token)
250 /// reads back — also the class a frontend's stylesheet selects on, which is
251 /// why it is CSS's own word for the same thing.
252 pub const fn name(self) -> &'static str {
253 match self {
254 Self::Center => "center",
255 Self::Right => "right",
256 Self::Justify => "justify",
257 }
258 }
259
260 /// This alignment's position in [`ALL`](Self::ALL) — the index a frontend's
261 /// own segmented control is keyed by, the way [`MarkColor::index`] keys a
262 /// palette.
263 pub const fn index(self) -> usize {
264 match self {
265 Self::Center => 0,
266 Self::Right => 1,
267 Self::Justify => 2,
268 }
269 }
270
271 /// Every alignment, in the order a toolbar offers them. Left is absent
272 /// because absence *is* left; a segmented control draws a fourth segment
273 /// for it and calls [`crate::Doc::set_alignment`] with `None`.
274 pub const ALL: [Self; 3] = [Self::Center, Self::Right, Self::Justify];
275}
276
277/// How far apart a block's lines are set, as a multiple of the theme's own line
278/// height — the spacing menu every word processor has, less the single spacing
279/// that is absence.
280///
281/// The tokens are the numbers rather than names (`loose`, `double`) so that a
282/// stylesheet's line is `line-height: 1.5` and a reader of the source sees the
283/// ratio. `1` is not a token, because `1` is absence and a document should not
284/// carry a key that says nothing.
285#[derive(Clone, Copy, PartialEq, Eq, Debug)]
286pub enum LineSpacing {
287 /// `1.15` — the word processor's default "a little more air".
288 OneFifteen,
289 /// `1.5`.
290 OneHalf,
291 /// `2` — double spacing.
292 Double,
293}
294
295impl LineSpacing {
296 /// The spacing a block's attributes name, if any — twig records it under
297 /// `data-line-height`.
298 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
299 attr(attrs, "data-line-height").and_then(Self::from_attr)
300 }
301
302 /// Read a `data-line-height` value. `None` for anything that is not one of
303 /// the three names — an exact ratio is [`LineHeight::Ratio`]'s to read, and
304 /// [`LineHeight::from_attr`] is the door that reads both.
305 pub fn from_attr(value: &str) -> Option<Self> {
306 Some(match value {
307 "1.15" => Self::OneFifteen,
308 "1.5" => Self::OneHalf,
309 "2" => Self::Double,
310 _ => return None,
311 })
312 }
313
314 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
315 /// reads back.
316 pub const fn name(self) -> &'static str {
317 match self {
318 Self::OneFifteen => "1.15",
319 Self::OneHalf => "1.5",
320 Self::Double => "2",
321 }
322 }
323
324 /// The ratio itself — what a frontend multiplies the theme's line height by
325 /// to lay the row out. A *derived* number, not a carried one: the document
326 /// says `1.5`, and this is that name read as arithmetic.
327 pub const fn ratio(self) -> f32 {
328 match self {
329 Self::OneFifteen => 1.15,
330 Self::OneHalf => 1.5,
331 Self::Double => 2.0,
332 }
333 }
334
335 /// This spacing's position in [`ALL`](Self::ALL) — the index a frontend's
336 /// own menu is keyed by.
337 pub const fn index(self) -> usize {
338 match self {
339 Self::OneFifteen => 0,
340 Self::OneHalf => 1,
341 Self::Double => 2,
342 }
343 }
344
345 /// Every spacing, in the order a menu offers them. Single is absent for the
346 /// reason `left` is absent from [`Align::ALL`].
347 pub const ALL: [Self; 3] = [Self::OneFifteen, Self::OneHalf, Self::Double];
348}
349
350/// How large a run is set relative to the text around it — CSS's
351/// `<absolute-size>` keyword set with `medium` removed, because `medium` is
352/// absence.
353///
354/// A *step*, never a measurement, for the reason [`MarkColor`] is a name and
355/// not a hex triple: a run set to `14pt` in a theme whose body is 12pt is a
356/// step up, and the same run under a 16pt theme is a step *down* — the author's
357/// intent inverted by a change they never made. A run set to `Large` is a step
358/// up under every theme.
359///
360/// A heading keeps its own ramp: a `data-size` on a heading scales the
361/// heading's size, not the body's.
362#[derive(Clone, Copy, PartialEq, Eq, Debug)]
363pub enum SizeStep {
364 XxSmall,
365 XSmall,
366 Small,
367 Large,
368 XLarge,
369 XxLarge,
370 XxxLarge,
371}
372
373impl SizeStep {
374 /// The size a run's or block's attributes name, if any — twig records it
375 /// under `data-size`.
376 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
377 attr(attrs, "data-size").and_then(Self::from_attr)
378 }
379
380 /// Read a `data-size` value. `None` for a name outside the vocabulary — a
381 /// `data-size="14pt"` is a measurement and [`FontSize::Points`]'s to read,
382 /// and [`FontSize::from_attr`] is the door that reads both.
383 pub fn from_attr(value: &str) -> Option<Self> {
384 Some(match value {
385 "xx-small" => Self::XxSmall,
386 "x-small" => Self::XSmall,
387 "small" => Self::Small,
388 "large" => Self::Large,
389 "x-large" => Self::XLarge,
390 "xx-large" => Self::XxLarge,
391 "xxx-large" => Self::XxxLarge,
392 _ => return None,
393 })
394 }
395
396 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
397 /// reads back — CSS's own keyword, so a stylesheet rule is
398 /// `[data-size="large"] { font-size: large }` and nothing is learned twice.
399 pub const fn name(self) -> &'static str {
400 match self {
401 Self::XxSmall => "xx-small",
402 Self::XSmall => "x-small",
403 Self::Small => "small",
404 Self::Large => "large",
405 Self::XLarge => "x-large",
406 Self::XxLarge => "xx-large",
407 Self::XxxLarge => "xxx-large",
408 }
409 }
410
411 /// The multiple of the theme's body size this step sets — the ratios CSS's
412 /// own user-agent stylesheet uses for the same seven words, so a browser
413 /// given only leaf's stylesheet and a native renderer given the theme agree
414 /// about how big `large` is.
415 ///
416 /// A *default*: a theme is free to scale its own ramp, the way a frontend
417 /// is free to pick its own red for [`MarkColor::Red`]. What the document
418 /// carries is the name.
419 pub const fn scale(self) -> f32 {
420 match self {
421 Self::XxSmall => 0.5625,
422 Self::XSmall => 0.625,
423 Self::Small => 0.8125,
424 Self::Large => 1.125,
425 Self::XLarge => 1.5,
426 Self::XxLarge => 2.0,
427 Self::XxxLarge => 3.0,
428 }
429 }
430
431 /// This step's position in [`ALL`](Self::ALL) — smallest first, so a
432 /// frontend's menu is `ALL` in order and a "larger" button is `index() + 1`.
433 pub const fn index(self) -> usize {
434 match self {
435 Self::XxSmall => 0,
436 Self::XSmall => 1,
437 Self::Small => 2,
438 Self::Large => 3,
439 Self::XLarge => 4,
440 Self::XxLarge => 5,
441 Self::XxxLarge => 6,
442 }
443 }
444
445 /// Every step, smallest first. `medium` is absent because `medium` is
446 /// absence — a menu draws it as the entry that calls
447 /// [`crate::Doc::set_font_size`] with `None`.
448 pub const ALL: [Self; 7] = [
449 Self::XxSmall,
450 Self::XSmall,
451 Self::Small,
452 Self::Large,
453 Self::XLarge,
454 Self::XxLarge,
455 Self::XxxLarge,
456 ];
457}
458
459/// The face a run is set in — CSS's generic families, less `fantasy` and
460/// `system-ui`, neither of which an author asks for.
461///
462/// A generic, never a font name, for [`SizeStep`]'s reason: a document that
463/// names `Georgia` renders in the fallback everywhere Georgia is not installed,
464/// which is every Linux terminal and most of the web. The theme names the
465/// concrete face for each — `Serif` is Georgia on a Mac and Noto Serif on a
466/// Linux box, and `Monospace` is the theme's mono face, which inline code
467/// already uses.
468///
469/// A named family is not in this vocabulary: [`from_attr`](Self::from_attr)
470/// answers `None` for it, and it is [`FontFace::Named`]'s to read — the open
471/// type beside this one, which the toolbar offers under the four generics.
472#[derive(Clone, Copy, PartialEq, Eq, Debug)]
473pub enum FontFamily {
474 Serif,
475 SansSerif,
476 Monospace,
477 Cursive,
478}
479
480impl FontFamily {
481 /// The face a run's or block's attributes name, if any — twig records it
482 /// under `data-font`.
483 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
484 attr(attrs, "data-font").and_then(Self::from_attr)
485 }
486
487 /// Read a `data-font` value. `None` for a concrete family name, which is
488 /// [`FontFace::Named`]'s to read; [`FontFace::from_attr`] is the door that
489 /// reads both.
490 ///
491 /// The four generics are matched without regard to case, because they are
492 /// CSS keywords and CSS reads a keyword either way — a hand-written
493 /// document says `Serif` as readily as `serif`, and a family actually
494 /// *named* "Serif" is not a thing anyone has installed. The canonical
495 /// spelling [`name`](Self::name) writes back is the lowercase one.
496 pub fn from_attr(value: &str) -> Option<Self> {
497 Self::ALL
498 .into_iter()
499 .find(|f| value.eq_ignore_ascii_case(f.name()))
500 }
501
502 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
503 /// reads back — CSS's own generic, so the stylesheet line is
504 /// `font-family: serif`.
505 pub const fn name(self) -> &'static str {
506 match self {
507 Self::Serif => "serif",
508 Self::SansSerif => "sans-serif",
509 Self::Monospace => "monospace",
510 Self::Cursive => "cursive",
511 }
512 }
513
514 /// This face's position in [`ALL`](Self::ALL) — the index a frontend's own
515 /// font table is keyed by.
516 pub const fn index(self) -> usize {
517 match self {
518 Self::Serif => 0,
519 Self::SansSerif => 1,
520 Self::Monospace => 2,
521 Self::Cursive => 3,
522 }
523 }
524
525 /// Every face, in the order a menu offers them. The theme's own body face
526 /// is absent because it is absence — the menu entry for it calls
527 /// [`crate::Doc::set_font_family`] with `None`.
528 pub const ALL: [Self; 4] = [Self::Serif, Self::SansSerif, Self::Monospace, Self::Cursive];
529}
530
531// ── the exact forms: a value where a name will not do ───────────────────────
532//
533// Each of the four run- and block-level properties gets an *open* type beside
534// its closed enum: the name, or the measurement the name cannot be. The enums
535// above are unchanged and still the menus' first offer — a name is portable
536// and a value is exact, and the author who takes the second takes the
537// portability cost knowingly.
538//
539// Each open type reads its own key with `from_attrs`, tries the **name first**
540// and the value second, and spells what it holds back with `name()`. A value
541// the grammar does not cover — `huge`, `14px`, `rgb(…)`, `1.3em` — answers
542// `None` exactly as it did before these types existed: carried untouched by the
543// document and drawn at the theme's default.
544
545/// A number a presentation value carries, in hundredths — 14 points is `1400`,
546/// a ratio of 1.3 is `130`.
547///
548/// Fixed point rather than an `f32` because a [`Style`] is stamped on every
549/// glyph and compared for run-merging, so the type has to be `Eq`, and because
550/// two spellings of the same number must be the same value: an author's
551/// `14.0pt` and the menu's `14pt` are one size, not two runs. Hundredths is
552/// more precision than any menu writes and enough for the `13.25pt` a fitted
553/// theme lands on.
554///
555/// `u16`, so the largest value is 655.35 — past any type size a sheet of paper
556/// holds and any line height a document means. A number above it is not a
557/// number this vocabulary carries, and reads as `None`.
558#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
559pub struct Hundredths(u16);
560
561impl Hundredths {
562 /// The number `value` names, rounded to the nearest hundredth. `None` for
563 /// anything that is not a positive finite number this can hold — the same
564 /// answer the parsers give a value outside the grammar.
565 pub fn from_f32(value: f32) -> Option<Self> {
566 if !value.is_finite() {
567 return None;
568 }
569 let h = (value * 100.0).round();
570 (1.0..=f32::from(u16::MAX))
571 .contains(&h)
572 .then_some(Self(h as u16))
573 }
574
575 /// The number itself — what a frontend lays out with.
576 pub fn as_f32(self) -> f32 {
577 self.0 as f32 / 100.0
578 }
579
580 /// The hundredths themselves, for a frontend that would rather do integer
581 /// arithmetic than divide and multiply back.
582 pub const fn hundredths(self) -> u16 {
583 self.0
584 }
585
586 /// Read a decimal — digits, at most one point, no sign and no exponent,
587 /// which is the whole of what a word processor's field writes. `None` for
588 /// anything else, and for zero: a size or a spacing of nothing is not a
589 /// value, it is a mistake.
590 ///
591 /// CSS's `<number>` wants digits on whichever side of the point it has, so
592 /// `.5` is a number and `14.` is not, and this reads them the same way.
593 ///
594 /// A third decimal place rounds rather than being refused, because the
595 /// number a colour picker or a font panel hands back is whatever floating
596 /// point made of the slider.
597 fn parse(value: &str) -> Option<Self> {
598 let (int, frac) = match value.split_once('.') {
599 // A trailing bare point is not a `<number>`: `14.` is a typo, and
600 // reading it as 14 would write a document the author did not mean.
601 Some((_, "")) => return None,
602 Some(pair) => pair,
603 None => (value, ""),
604 };
605 if int.is_empty() && frac.is_empty() {
606 return None;
607 }
608 if !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit()) {
609 return None;
610 }
611 let whole: u32 = if int.is_empty() { 0 } else { int.parse().ok()? };
612 // Out of range before the arithmetic rather than after it, so a
613 // thousand digits of integer part is a `None` and not an overflow.
614 if whole > u32::from(u16::MAX) / 100 {
615 return None;
616 }
617 let digit = |i: usize| frac.as_bytes().get(i).map_or(0, |b| u32::from(b - b'0'));
618 let mut h = whole * 100 + digit(0) * 10 + digit(1);
619 if digit(2) >= 5 {
620 h += 1;
621 }
622 (1..=u32::from(u16::MAX))
623 .contains(&h)
624 .then_some(Self(h as u16))
625 }
626}
627
628impl std::fmt::Display for Hundredths {
629 /// The shortest decimal that means this number: `14`, `13.5`, `1.25`. What
630 /// a document is written with, so that a value set twice from the same menu
631 /// spells the same bytes both times.
632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633 let (whole, frac) = (self.0 / 100, self.0 % 100);
634 match frac {
635 0 => write!(f, "{whole}"),
636 _ if frac % 10 == 0 => write!(f, "{whole}.{}", frac / 10),
637 _ => write!(f, "{whole}.{frac:02}"),
638 }
639 }
640}
641
642/// How large a run is set: a [`SizeStep`] relative to the text around it, or
643/// the point size the author asked for.
644///
645/// The step is what a menu offers first, and is what a document should say
646/// where a name will do — it reads as a step up under every theme. The point
647/// size is what the author typed and is all it is: 14 points of the sheet on
648/// the paginated view, and 14 points before the zoom on screen, which is how
649/// every other point size on a page behaves. A heading set to an exact size is
650/// that size and not its ramp scaled — the name scales the ramp, the value
651/// replaces it.
652///
653/// Only `pt` is a unit. `px` is a screen's unit and a document is not a screen;
654/// `em`, `rem` and `%` are relative, which is what the steps already are.
655#[derive(Clone, Copy, PartialEq, Eq, Debug)]
656pub enum FontSize {
657 Step(SizeStep),
658 Points(Hundredths),
659}
660
661impl FontSize {
662 /// The size a run's or block's attributes name, if any — twig records it
663 /// under `data-size`.
664 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
665 attr(attrs, "data-size").and_then(Self::from_attr)
666 }
667
668 /// Read a `data-size` value: one of CSS's seven keywords, or a `<number>pt`.
669 /// The unit is matched without regard to case, because a hand-written
670 /// document says `14PT` as readily as `14pt` and CSS reads both.
671 pub fn from_attr(value: &str) -> Option<Self> {
672 let value = value.trim();
673 if let Some(step) = SizeStep::from_attr(value) {
674 return Some(Self::Step(step));
675 }
676 let number = value.strip_suffix("pt").or_else(|| {
677 let (n, unit) = value.split_at_checked(value.len().checked_sub(2)?)?;
678 unit.eq_ignore_ascii_case("pt").then_some(n)
679 })?;
680 Hundredths::parse(number).map(Self::Points)
681 }
682
683 /// A size in points, or `None` for a number this vocabulary cannot carry —
684 /// the constructor an *Other…* field calls with what the author typed.
685 pub fn points(points: f32) -> Option<Self> {
686 Hundredths::from_f32(points).map(Self::Points)
687 }
688
689 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
690 /// reads back: the step's CSS keyword, or the shortest decimal with `pt`
691 /// after it.
692 pub fn name(self) -> Cow<'static, str> {
693 match self {
694 Self::Step(step) => Cow::Borrowed(step.name()),
695 Self::Points(pt) => Cow::Owned(format!("{pt}pt")),
696 }
697 }
698
699 /// The step, for a menu asking which of its seven rows to tick — `None`
700 /// when the size is an exact one, which the menu shows as its own row.
701 pub const fn step(self) -> Option<SizeStep> {
702 match self {
703 Self::Step(step) => Some(step),
704 Self::Points(_) => None,
705 }
706 }
707
708 /// The point size, or `None` when the size is a step — whose size in points
709 /// is the theme's business and not this type's.
710 pub fn points_value(self) -> Option<f32> {
711 match self {
712 Self::Step(_) => None,
713 Self::Points(pt) => Some(pt.as_f32()),
714 }
715 }
716}
717
718/// How far apart a block's lines are set: a [`LineSpacing`] from the menu's
719/// three, or the ratio the author asked for. [`FontSize`]'s peer, one property
720/// along, and with no unit at all — a line height is a multiple.
721///
722/// A ratio that spells one of the three names *is* that name:
723/// [`from_attr`](Self::from_attr) answers `Step(OneHalf)` for `1.50` as well as
724/// for `1.5`, so that two spellings of one spacing are one value and the
725/// document is rewritten with the name a menu can tick.
726///
727/// And a ratio of **1 is absence**, exactly as it is for [`LineSpacing`], whose
728/// three names begin above it: single spacing is what a block with no
729/// `data-line-height` is set at, and a document should not carry a key that
730/// says nothing. `from_attr("1")` and `ratio(1.0)` both answer `None`, so a
731/// gesture given one clears the key and the query then ticks *Single*.
732#[derive(Clone, Copy, PartialEq, Eq, Debug)]
733pub enum LineHeight {
734 Step(LineSpacing),
735 Ratio(Hundredths),
736}
737
738impl LineHeight {
739 /// The spacing a block's attributes name, if any — twig records it under
740 /// `data-line-height`.
741 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
742 attr(attrs, "data-line-height").and_then(Self::from_attr)
743 }
744
745 /// Read a `data-line-height` value: one of the three names, or any positive
746 /// decimal other than 1, which is absence.
747 pub fn from_attr(value: &str) -> Option<Self> {
748 let value = value.trim();
749 if let Some(step) = LineSpacing::from_attr(value) {
750 return Some(Self::Step(step));
751 }
752 Hundredths::parse(value).and_then(Self::of)
753 }
754
755 /// A ratio, or `None` for a number this vocabulary cannot carry — the
756 /// constructor an *Other…* field calls with what the author typed. 1 is one
757 /// of those numbers: see the type's note.
758 pub fn ratio(ratio: f32) -> Option<Self> {
759 Hundredths::from_f32(ratio).and_then(Self::of)
760 }
761
762 /// Whether `value` spells the one ratio that is **absence** — `1`, in any
763 /// of its spellings (`1`, `1.0`, `1.00`), which is single spacing and is
764 /// the theme's own.
765 ///
766 /// [`from_attr`](Self::from_attr) answers `None` for it exactly as it does
767 /// for a token outside the grammar, and the two mean opposite things: `1`
768 /// is an author asking for the theme's spacing, and `huge` is a typo. A
769 /// binding that turns the first into a cleared key and the second into a
770 /// refusal asks this to tell them apart — leaf-wasm's `setLineSpacing`
771 /// does.
772 pub fn is_absence(value: &str) -> bool {
773 Hundredths::parse(value.trim()).is_some_and(|r| r.hundredths() == Self::SINGLE)
774 }
775
776 /// Single spacing in hundredths — the ratio that has no token, written
777 /// down once for [`of`](Self::of) and [`is_absence`](Self::is_absence).
778 const SINGLE: u16 = 100;
779
780 /// A ratio as the name for it where there is one, and `None` where the
781 /// ratio is 1 — see the type's note.
782 ///
783 /// Compared in hundredths rather than through [`Display`](std::fmt::Display)
784 /// and [`LineSpacing::from_attr`], so that reading a spacing does not format
785 /// a `String` to throw away.
786 fn of(ratio: Hundredths) -> Option<Self> {
787 if ratio.hundredths() == Self::SINGLE {
788 return None;
789 }
790 let step = LineSpacing::ALL
791 .into_iter()
792 .find(|s| (s.ratio() * 100.0).round() as u16 == ratio.hundredths());
793 Some(match step {
794 Some(step) => Self::Step(step),
795 None => Self::Ratio(ratio),
796 })
797 }
798
799 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
800 /// reads back.
801 pub fn name(self) -> Cow<'static, str> {
802 match self {
803 Self::Step(step) => Cow::Borrowed(step.name()),
804 Self::Ratio(r) => Cow::Owned(r.to_string()),
805 }
806 }
807
808 /// The step, for a menu asking which of its three rows to tick — `None` for
809 /// an exact ratio, which the menu shows as its own row.
810 pub const fn step(self) -> Option<LineSpacing> {
811 match self {
812 Self::Step(step) => Some(step),
813 Self::Ratio(_) => None,
814 }
815 }
816
817 /// The ratio itself — what a frontend multiplies the theme's line height
818 /// by. Unlike [`FontSize`]'s points this always answers, because a step's
819 /// ratio is the name read as arithmetic ([`LineSpacing::ratio`]) and not a
820 /// theme's choice.
821 pub fn as_f32(self) -> f32 {
822 match self {
823 Self::Step(step) => step.ratio(),
824 Self::Ratio(r) => r.as_f32(),
825 }
826 }
827}
828
829/// A run's foreground colour: one of the seven [`MarkColor`] names, or the RGB
830/// triple the author asked for.
831///
832/// A name is two inks, one per appearance, and the theme owns both. A triple is
833/// painted as written in the light appearance and in the dark one alike — that
834/// is what "exact" means, and the proposal does not soften it with a heuristic.
835/// `#rgb` is read and never written; six lowercase digits is the spelling.
836#[derive(Clone, Copy, PartialEq, Eq, Debug)]
837pub enum TextColor {
838 Named(MarkColor),
839 Rgb { r: u8, g: u8, b: u8 },
840}
841
842impl TextColor {
843 /// The colour a run's or block's attributes name, if any — twig records it
844 /// under `data-color`, the key a `mark` node carries its *highlight's*
845 /// colour under. The two never collide: a `mark` is a `mark` and a span is
846 /// a span.
847 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
848 attr(attrs, "data-color").and_then(Self::from_attr)
849 }
850
851 /// Read a `data-color` value: one of the seven names, `#rrggbb`, or the
852 /// `#rgb` shorthand a stylesheet author writes (`#f00` is `#ff0000`, each
853 /// digit doubled, as CSS expands it).
854 pub fn from_attr(value: &str) -> Option<Self> {
855 let value = value.trim();
856 if let Some(named) = MarkColor::from_attr(value) {
857 return Some(Self::Named(named));
858 }
859 let hex = value.strip_prefix('#')?;
860 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
861 return None;
862 }
863 let nib = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok();
864 let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
865 match hex.len() {
866 3 => Some(Self::Rgb {
867 r: nib(0)? * 0x11,
868 g: nib(1)? * 0x11,
869 b: nib(2)? * 0x11,
870 }),
871 6 => Some(Self::Rgb {
872 r: byte(0)?,
873 g: byte(2)?,
874 b: byte(4)?,
875 }),
876 _ => None,
877 }
878 }
879
880 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
881 /// reads back: the name, or six lowercase hex digits behind a `#`.
882 pub fn name(self) -> Cow<'static, str> {
883 match self {
884 Self::Named(c) => Cow::Borrowed(c.name()),
885 Self::Rgb { r, g, b } => Cow::Owned(format!("#{r:02x}{g:02x}{b:02x}")),
886 }
887 }
888
889 /// The name, for a palette asking which of its seven swatches to tick —
890 /// `None` for an exact triple, which the palette shows as a swatch of its
891 /// own.
892 pub const fn named(self) -> Option<MarkColor> {
893 match self {
894 Self::Named(c) => Some(c),
895 Self::Rgb { .. } => None,
896 }
897 }
898
899 /// The triple, or `None` for a name — whose two inks are the theme's.
900 pub const fn rgb(self) -> Option<(u8, u8, u8)> {
901 match self {
902 Self::Named(_) => None,
903 Self::Rgb { r, g, b } => Some((r, g, b)),
904 }
905 }
906}
907
908/// The face a run is set in: one of CSS's four generics, or the family the
909/// author named.
910///
911/// A generic opens on every machine and a family name does not, which is the
912/// trade stated once in [`FontFamily`]'s own note. A named family is resolved
913/// through the platform's font registry by the frontends that have one, and
914/// falls back to the body face where it is not installed.
915///
916/// Owned, because a family name is a `String` and this is what a gesture takes
917/// and a query answers — neither is per-glyph. What a *glyph* carries is
918/// [`FaceRef`], the `Copy` half, whose name is looked up in the map's
919/// [`FaceTable`].
920#[derive(Clone, PartialEq, Eq, Debug)]
921pub enum FontFace {
922 Generic(FontFamily),
923 Named(String),
924}
925
926impl FontFace {
927 /// The face a run's or block's attributes name, if any — twig records it
928 /// under `data-font`.
929 pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
930 attr(attrs, "data-font").and_then(Self::from_attr)
931 }
932
933 /// Read a `data-font` value: one of the four generics, or any other
934 /// non-empty string, which is a family name. Trimmed, and nothing else —
935 /// a family name is what the author typed, and leaf has no table of real
936 /// ones to check it against.
937 pub fn from_attr(value: &str) -> Option<Self> {
938 let value = value.trim();
939 match FontFamily::from_attr(value) {
940 Some(generic) => Some(Self::Generic(generic)),
941 None => (!value.is_empty()).then(|| Self::Named(value.to_string())),
942 }
943 }
944
945 /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
946 /// reads back — the generic's CSS keyword, or the family name as given.
947 pub fn name(&self) -> Cow<'_, str> {
948 match self {
949 Self::Generic(generic) => Cow::Borrowed(generic.name()),
950 Self::Named(name) => Cow::Borrowed(name.as_str()),
951 }
952 }
953
954 /// The generic, for a menu asking which of its four rows to tick — `None`
955 /// for a named family, which the menu shows as a row of its own.
956 pub const fn generic(&self) -> Option<FontFamily> {
957 match self {
958 Self::Generic(generic) => Some(*generic),
959 Self::Named(_) => None,
960 }
961 }
962}
963
964/// A named family's id on a glyph — what [`FaceRef::Named`] carries and
965/// [`FaceTable::name`] reads back.
966///
967/// A 32-bit FNV-1a of the family name, and *not* an index, for one reason: a
968/// glyph's id has to mean the same thing however its row was built. A row comes
969/// from a fresh walk, from a [`crate::wysiwyg::BlockCache`] hit cloned at a
970/// shifted offset, or from a previous map a splice kept untouched — and an
971/// index into a table that each of those three assembled differently would have
972/// the same glyph naming two faces. Derived from the name, nothing has to be
973/// remapped and a spliced map's glyphs compare equal to a fresh build's.
974///
975/// Two family names that hashed alike would draw in one face. That needs about
976/// 2¹⁶ distinct families in one document to become likely, and a document with
977/// 2¹⁶ families has a different problem.
978#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
979pub struct FaceId(u32);
980
981impl FaceId {
982 /// The id a family name has. FNV-1a, written out rather than taken from
983 /// `DefaultHasher`, because the value is compared across builds and must
984 /// not depend on a hasher's seed or version.
985 pub fn of(name: &str) -> Self {
986 let mut h: u32 = 0x811c_9dc5;
987 for b in name.as_bytes() {
988 h ^= u32::from(*b);
989 h = h.wrapping_mul(0x0100_0193);
990 }
991 Self(h)
992 }
993}
994
995/// The face a *glyph* is set in — [`FontFace`]'s `Copy` half, so that a
996/// [`Style`] stays `Copy` and `Eq` and can be stamped on every glyph and
997/// compared for run-merging.
998///
999/// A named family is an id into the map's [`FaceTable`], which the walker
1000/// interns into as it meets each name. A frontend reads the name back with
1001/// [`crate::wysiwyg::VisualMap::face_name`].
1002#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1003pub enum FaceRef {
1004 Generic(FontFamily),
1005 Named(FaceId),
1006}
1007
1008impl FaceRef {
1009 /// The generic, or `None` for a named family — [`FontFace::generic`]'s peer.
1010 pub const fn generic(self) -> Option<FontFamily> {
1011 match self {
1012 Self::Generic(generic) => Some(generic),
1013 Self::Named(_) => None,
1014 }
1015 }
1016
1017 /// The id of the named family, or `None` for a generic.
1018 pub const fn id(self) -> Option<FaceId> {
1019 match self {
1020 Self::Generic(_) => None,
1021 Self::Named(id) => Some(id),
1022 }
1023 }
1024}
1025
1026/// Every named family a [`crate::wysiwyg::VisualMap`] draws, by the id its
1027/// glyphs carry — the side table that lets [`Style`] stay `Copy` while a face
1028/// name stays a `String`.
1029///
1030/// Small: one entry per *distinct* family name in the document, which is
1031/// normally none. A splice may leave an entry no glyph names any more, because
1032/// the splice reuses the previous map's table rather than rebuilding it from
1033/// rows it deliberately did not walk; an unused entry costs a string and draws
1034/// nothing.
1035#[derive(Clone, Default, Debug, PartialEq, Eq)]
1036pub struct FaceTable {
1037 /// Ascending by id, so a lookup is a binary search and two tables built
1038 /// from the same names compare equal whatever order they met them in.
1039 entries: Vec<(FaceId, String)>,
1040}
1041
1042impl FaceTable {
1043 /// The family name `id` stands for, or `None` for an id from another map —
1044 /// which a frontend draws in the theme's body face, as it draws a face it
1045 /// cannot resolve.
1046 pub fn name(&self, id: FaceId) -> Option<&str> {
1047 let i = self.entries.binary_search_by_key(&id, |(k, _)| *k).ok()?;
1048 Some(self.entries[i].1.as_str())
1049 }
1050
1051 /// The face a glyph carries, spelled — the generic's CSS keyword, or the
1052 /// family name this table holds for the id. The one place a [`FaceRef`]
1053 /// becomes the token a `data-font` is written with, which is what every
1054 /// frontend wants of it.
1055 ///
1056 /// `None` for an id no table knows, which is an id from another map: a
1057 /// frontend draws that in the theme's body face, and a face that draws as
1058 /// absence names itself as absence too.
1059 ///
1060 /// leaf-ffi's and leaf-wasm's `Run::font` are this; leaf-swift's and
1061 /// leaf-ratatui's face resolvers can be.
1062 pub fn spell(&self, face: FaceRef) -> Option<Cow<'_, str>> {
1063 match face {
1064 FaceRef::Generic(generic) => Some(Cow::Borrowed(generic.name())),
1065 FaceRef::Named(id) => self.name(id).map(Cow::Borrowed),
1066 }
1067 }
1068
1069 /// Every family in the table, by id — how a frontend warms a font cache
1070 /// before it draws.
1071 pub fn iter(&self) -> impl Iterator<Item = (FaceId, &str)> {
1072 self.entries.iter().map(|(id, name)| (*id, name.as_str()))
1073 }
1074
1075 pub fn is_empty(&self) -> bool {
1076 self.entries.is_empty()
1077 }
1078
1079 pub fn len(&self) -> usize {
1080 self.entries.len()
1081 }
1082
1083 /// Record one family, keeping the vec ascending by id — the sorted insert
1084 /// both doors below are, written once.
1085 fn insert(&mut self, id: FaceId, name: &str) {
1086 if let Err(i) = self.entries.binary_search_by_key(&id, |(k, _)| *k) {
1087 self.entries.insert(i, (id, name.to_string()));
1088 }
1089 }
1090
1091 /// Record `face` and hand back what a glyph carries for it. A generic needs
1092 /// no entry — it names itself.
1093 pub(crate) fn intern(&mut self, face: &FontFace) -> FaceRef {
1094 match face {
1095 FontFace::Generic(generic) => FaceRef::Generic(*generic),
1096 FontFace::Named(name) => {
1097 let id = FaceId::of(name);
1098 self.insert(id, name);
1099 FaceRef::Named(id)
1100 }
1101 }
1102 }
1103
1104 /// The face `attrs` name, interned — the walker's door.
1105 pub(crate) fn face_from_attrs(
1106 &mut self,
1107 attrs: &[(String, Option<String>)],
1108 ) -> Option<FaceRef> {
1109 FontFace::from_attrs(attrs).map(|face| self.intern(&face))
1110 }
1111
1112 /// Take in everything `other` knows — how a build assembles one table out
1113 /// of the per-block walks, cache hits and spliced remnants it is made of.
1114 pub(crate) fn merge(&mut self, other: &FaceTable) {
1115 for (id, name) in &other.entries {
1116 self.insert(*id, name);
1117 }
1118 }
1119}
1120
1121/// What a glyph in a fenced code block is *to the language it is written in*
1122/// — the syntax-highlighting vocabulary, one level down from [`Role`].
1123///
1124/// Eight classes rather than the hundreds of scopes a Sublime grammar names,
1125/// and the same eight `plates` colours its published pages with: each is the
1126/// *first atom* of a TextMate scope — `keyword.control.rust` is a keyword,
1127/// `string.quoted.double` a string — and a palette that tells these eight apart
1128/// is a palette that reads. Finer than this is a theme's business, and a theme
1129/// is exactly what core does not carry: like [`Role`], a token says what a
1130/// glyph *is* and leaves what colour to paint it to the frontend, so the same
1131/// document highlights in ANSI colours on a terminal and in an `Hsla` in a GUI.
1132///
1133/// Only a glyph with [`Role::Code`] carries one, and only in a fenced block
1134/// whose info string names a language the bundled grammars know (see
1135/// [`crate::syntax`]). Inline `` `code` ``, an indented block, a bare fence, and
1136/// a language no grammar covers all carry `None`, and draw in the code colour
1137/// exactly as they did before this existed.
1138#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1139pub enum Token {
1140 /// Brackets, operators, separators — `punctuation.*`. The quietest class:
1141 /// a frontend typically draws it in the comment colour, so the `"` opening
1142 /// a string reads as the string's and not as its own thing.
1143 Punctuation,
1144 /// A reserved word or a storage modifier — `keyword.*` and `storage.*`
1145 /// (`fn`, `let`, `pub`, `const`, `if`).
1146 Keyword,
1147 /// A name the author defined or named — `entity.*` (a function or type at
1148 /// its definition) and `variable.*` (a parameter, a field, `self`).
1149 Entity,
1150 /// A name the language or its library provides — `support.*` (a builtin
1151 /// function, a standard type).
1152 Support,
1153 /// A literal that is not a string — `constant.*` (a number, `true`, an
1154 /// escape sequence, a character literal).
1155 Constant,
1156 /// A string literal, delimiters included — `string.*`.
1157 String,
1158 /// A comment — `comment.*`. A frontend typically italicises it as well.
1159 Comment,
1160 /// What the grammar could not parse — `invalid.*`.
1161 Invalid,
1162}
1163
1164impl Token {
1165 /// The class id a frontend keys a stylesheet or a palette on — the first
1166 /// atom of the scope it stands for, so a rule written for `plates` output
1167 /// (`plates-keyword`) and one written for leaf (`leaf-t-keyword`) name the
1168 /// same thing.
1169 pub const fn name(self) -> &'static str {
1170 match self {
1171 Self::Punctuation => "punctuation",
1172 Self::Keyword => "keyword",
1173 Self::Entity => "entity",
1174 Self::Support => "support",
1175 Self::Constant => "constant",
1176 Self::String => "string",
1177 Self::Comment => "comment",
1178 Self::Invalid => "invalid",
1179 }
1180 }
1181
1182 /// The token a class id names, or `None` for a name outside the
1183 /// vocabulary — the inverse of [`name`](Self::name).
1184 pub fn from_name(name: &str) -> Option<Self> {
1185 Self::ALL.into_iter().find(|t| t.name() == name)
1186 }
1187
1188 /// This token's position in [`ALL`](Self::ALL) — the index a frontend's own
1189 /// palette array is keyed by, the way [`MarkColor::index`] keys the
1190 /// highlighter washes.
1191 pub const fn index(self) -> usize {
1192 match self {
1193 Self::Punctuation => 0,
1194 Self::Keyword => 1,
1195 Self::Entity => 2,
1196 Self::Support => 3,
1197 Self::Constant => 4,
1198 Self::String => 5,
1199 Self::Comment => 6,
1200 Self::Invalid => 7,
1201 }
1202 }
1203
1204 /// Every token, in **precedence order**: a scope whose atoms name two of
1205 /// these (`punctuation.definition.string.begin` is both punctuation and
1206 /// string) is the *later* one, so a string's quotes read as string and a
1207 /// comment's `//` as comment. The frontends iterate this to build their
1208 /// palettes, so a token added here is one a palette test immediately
1209 /// demands an entry for.
1210 pub const ALL: [Self; 8] = [
1211 Self::Punctuation,
1212 Self::Keyword,
1213 Self::Entity,
1214 Self::Support,
1215 Self::Constant,
1216 Self::String,
1217 Self::Comment,
1218 Self::Invalid,
1219 ];
1220}
1221
1222/// Which line a glyph sits on relative to the text around it.
1223///
1224/// Not a [`Role`], because a raised glyph keeps whatever it already was — the
1225/// `1` of a footnote reference is still a link, an author's `^2^` inside a
1226/// heading is still heading text. And not one of [`Style`]'s `bool` flags,
1227/// because unlike bold-and-italic these do not compose: a glyph is raised, or
1228/// lowered, or neither, and two flags would let a caller ask for both.
1229///
1230/// A frontend that ignores this draws every glyph on the normal baseline, which
1231/// is what every frontend did before the variant existed.
1232#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
1233pub enum Baseline {
1234 /// The ordinary text baseline.
1235 #[default]
1236 Normal,
1237 /// Raised and typically drawn smaller — an author's `^x^`, and the label of
1238 /// a footnote reference.
1239 Super,
1240 /// Lowered and typically drawn smaller — an author's `~x~`.
1241 Sub,
1242}
1243
1244/// A glyph's style: a typographic [`Role`] plus the compositional emphasis flags
1245/// the author wrote. Deliberately *no* color — that is a frontend's call, keyed
1246/// on the [`Role`]. Builder methods (`.bold`, `.italic`, …) mirror the shape of
1247/// ratatui's `Style` so the WYSIWYG builder reads the same as it did before the
1248/// split.
1249#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
1250pub struct Style {
1251 pub bold: bool,
1252 pub italic: bool,
1253 pub underline: bool,
1254 pub strikethrough: bool,
1255 /// The typographic role — [`Role::Body`] for ordinary text.
1256 pub role: Role,
1257 /// Which line the glyph sits on — [`Baseline::Normal`] for ordinary text.
1258 pub baseline: Baseline,
1259 /// What a code glyph is to its language — `None` for every glyph outside a
1260 /// highlighted fenced block, which is every glyph there was before syntax
1261 /// highlighting. Only meaningful beside [`Role::Code`]; a frontend that
1262 /// ignores it draws code in one colour, as every frontend once did.
1263 pub token: Option<Token>,
1264 /// How large this run is set — the author's `data-size` as a step or a
1265 /// point size, and `None` for the theme's own size, which is every glyph
1266 /// there was before the presentation vocabulary.
1267 ///
1268 /// Read at both levels, the nearer winning: a span's `data-size` inside a
1269 /// block carrying its own applies to the span. A frontend that ignores it
1270 /// draws one size, as `leaf-ratatui` does — a cell has one size.
1271 pub size: Option<FontSize>,
1272 /// The face this run is set in — the author's `data-font` as a generic or
1273 /// as an id into the map's [`FaceTable`], and `None` for the theme's body
1274 /// face. Read at the same two levels [`size`](Self::size) is.
1275 pub font: Option<FaceRef>,
1276 /// The run's *foreground* colour — the author's `data-color` on an
1277 /// attributed span, and `None` for the theme's text colour.
1278 ///
1279 /// The same seven names [`Role::Mark`] carries, over the same enum: a
1280 /// frontend that has a red for a highlight has a red for text, and both
1281 /// should be *that* red. The two never collide, because a `mark` is a
1282 /// `mark` and a span is a span — a `data-color` on a `mark` node is the
1283 /// highlight's background and reaches a glyph through its role, while this
1284 /// is what a `<span data-color="red">` paints the letters. Only this one
1285 /// opens to a triple, for the reason [`MarkColor`]'s note gives.
1286 pub color: Option<TextColor>,
1287}
1288
1289impl Style {
1290 pub const fn bold(mut self) -> Self {
1291 self.bold = true;
1292 self
1293 }
1294
1295 pub const fn italic(mut self) -> Self {
1296 self.italic = true;
1297 self
1298 }
1299
1300 pub const fn underline(mut self) -> Self {
1301 self.underline = true;
1302 self
1303 }
1304
1305 pub const fn strikethrough(mut self) -> Self {
1306 self.strikethrough = true;
1307 self
1308 }
1309
1310 pub const fn role(mut self, r: Role) -> Self {
1311 self.role = r;
1312 self
1313 }
1314
1315 pub const fn baseline(mut self, b: Baseline) -> Self {
1316 self.baseline = b;
1317 self
1318 }
1319
1320 pub const fn token(mut self, t: Option<Token>) -> Self {
1321 self.token = t;
1322 self
1323 }
1324
1325 pub const fn size(mut self, s: Option<FontSize>) -> Self {
1326 self.size = s;
1327 self
1328 }
1329
1330 pub const fn font(mut self, f: Option<FaceRef>) -> Self {
1331 self.font = f;
1332 self
1333 }
1334
1335 pub const fn color(mut self, c: Option<TextColor>) -> Self {
1336 self.color = c;
1337 self
1338 }
1339}
1340
1341#[cfg(test)]
1342mod tests {
1343 use super::*;
1344
1345 /// [`MarkColor`] carries three hand-written tables — `ALL`, `index`, and the
1346 /// `name`/`from_attr` pair — and nothing but this makes them agree. The
1347 /// frontends index their palettes by `index` and look colours up by `name`,
1348 /// so a variant added to one table and missed in another draws the wrong
1349 /// wash rather than failing to compile.
1350 #[test]
1351 fn the_colour_tables_agree_with_each_other() {
1352 for (i, c) in MarkColor::ALL.into_iter().enumerate() {
1353 assert_eq!(c.index(), i, "{} is not where ALL puts it", c.name());
1354 assert_eq!(MarkColor::from_attr(c.name()), Some(c), "name round-trip");
1355 }
1356 assert_eq!(MarkColor::from_attr("chartreuse"), None);
1357 assert_eq!(MarkColor::from_attr(""), None);
1358 }
1359
1360 /// [`Token`] carries the same three tables [`MarkColor`] does, held to
1361 /// each other for the same reason: a frontend indexes its palette by
1362 /// `index` and a stylesheet keys on `name`.
1363 #[test]
1364 fn the_token_tables_agree_with_each_other() {
1365 for (i, t) in Token::ALL.into_iter().enumerate() {
1366 assert_eq!(t.index(), i, "{} is not where ALL puts it", t.name());
1367 assert_eq!(Token::from_name(t.name()), Some(t), "name round-trip");
1368 }
1369 assert_eq!(Token::from_name("meta"), None);
1370 assert_eq!(Token::from_name(""), None);
1371 }
1372
1373 /// Each presentation enum carries the same three hand-written tables
1374 /// [`MarkColor`] does — `ALL`, `index`, and the `name`/`from_*` pair — and
1375 /// nothing but this makes them agree. A frontend indexes its own menu by
1376 /// `index` and a stylesheet keys on `name`, so a variant added to one table
1377 /// and missed in another draws the wrong thing rather than failing to
1378 /// compile.
1379 #[test]
1380 fn the_presentation_tables_agree_with_each_other() {
1381 for (i, a) in Align::ALL.into_iter().enumerate() {
1382 assert_eq!(a.index(), i, "{} is not where ALL puts it", a.name());
1383 assert_eq!(Align::from_token(a.name()), Some(a), "name round-trip");
1384 }
1385 for (i, l) in LineSpacing::ALL.into_iter().enumerate() {
1386 assert_eq!(l.index(), i, "{} is not where ALL puts it", l.name());
1387 assert_eq!(LineSpacing::from_attr(l.name()), Some(l), "name round-trip");
1388 }
1389 for (i, z) in SizeStep::ALL.into_iter().enumerate() {
1390 assert_eq!(z.index(), i, "{} is not where ALL puts it", z.name());
1391 assert_eq!(SizeStep::from_attr(z.name()), Some(z), "name round-trip");
1392 }
1393 for (i, f) in FontFamily::ALL.into_iter().enumerate() {
1394 assert_eq!(f.index(), i, "{} is not where ALL puts it", f.name());
1395 assert_eq!(FontFamily::from_attr(f.name()), Some(f), "name round-trip");
1396 }
1397 // Nothing outside the vocabulary is guessed at.
1398 assert_eq!(Align::from_token("left"), None);
1399 assert_eq!(LineSpacing::from_attr("1"), None);
1400 assert_eq!(SizeStep::from_attr("medium"), None);
1401 assert_eq!(SizeStep::from_attr("14pt"), None);
1402 assert_eq!(FontFamily::from_attr("Garamond"), None);
1403 assert_eq!(FontFamily::from_attr("fantasy"), None);
1404 // A generic is a CSS keyword, and CSS reads a keyword either way — the
1405 // spelling written back is still the lowercase one.
1406 assert_eq!(FontFamily::from_attr("Serif"), Some(FontFamily::Serif));
1407 assert_eq!(
1408 FontFamily::from_attr("SANS-SERIF"),
1409 Some(FontFamily::SansSerif)
1410 );
1411 assert_eq!(
1412 FontFamily::from_attr("MonoSpace").unwrap().name(),
1413 "monospace"
1414 );
1415 assert_eq!(
1416 FontFace::from_attr("Serif"),
1417 Some(FontFace::Generic(FontFamily::Serif)),
1418 "and the open type reads it as the generic, not as a family name"
1419 );
1420 assert_eq!(FontFace::from_attr("Serif").unwrap().name(), "serif");
1421 }
1422
1423 /// The size ramp's defaults are CSS's own user-agent ratios for the same
1424 /// seven words, so a browser given only leaf's stylesheet and a native
1425 /// renderer given the theme agree about how big `large` is. Pinned because
1426 /// they are the one place core carries a *number*.
1427 #[test]
1428 fn the_size_ramp_is_css_s_own() {
1429 let ramp: Vec<f32> = SizeStep::ALL.into_iter().map(SizeStep::scale).collect();
1430 assert_eq!(
1431 ramp,
1432 vec![0.5625, 0.625, 0.8125, 1.125, 1.5, 2.0, 3.0],
1433 "the CSS absolute-size ratios, medium removed"
1434 );
1435 // Monotonic, and `medium` (1.0) is the gap absence sits in.
1436 assert!(ramp.windows(2).all(|w| w[0] < w[1]));
1437 assert!(SizeStep::Small.scale() < 1.0 && SizeStep::Large.scale() > 1.0);
1438 let spacing: Vec<f32> = LineSpacing::ALL
1439 .into_iter()
1440 .map(LineSpacing::ratio)
1441 .collect();
1442 assert_eq!(spacing, vec![1.15, 1.5, 2.0]);
1443 }
1444
1445 /// `class` is a token list, and leaf reads the one token it knows out of it
1446 /// and leaves the rest — the rule that lets a document from elsewhere pass
1447 /// through the editor unharmed.
1448 #[test]
1449 fn an_alignment_is_one_token_of_a_class_and_the_rest_is_somebody_else_s() {
1450 let class = |v: &str| vec![("class".to_string(), Some(v.to_string()))];
1451 assert_eq!(Align::from_attrs(&class("center")), Some(Align::Center));
1452 assert_eq!(
1453 Align::from_attrs(&class("lead center")),
1454 Some(Align::Center)
1455 );
1456 assert_eq!(
1457 Align::from_attrs(&class("center lead")),
1458 Some(Align::Center)
1459 );
1460 assert_eq!(Align::from_attrs(&class("lead wide")), None);
1461 assert_eq!(Align::from_attrs(&class("")), None);
1462 assert_eq!(Align::from_attrs(&[]), None);
1463 // A bare `class` has no token list to read.
1464 assert_eq!(Align::from_attrs(&[("class".to_string(), None)]), None);
1465 // The other three read their own key and nothing else.
1466 let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
1467 assert_eq!(
1468 LineSpacing::from_attrs(&attr("data-line-height", "1.5")),
1469 Some(LineSpacing::OneHalf)
1470 );
1471 assert_eq!(LineSpacing::from_attrs(&attr("class", "1.5")), None);
1472 assert_eq!(
1473 SizeStep::from_attrs(&attr("data-size", "large")),
1474 Some(SizeStep::Large)
1475 );
1476 assert_eq!(
1477 FontFamily::from_attrs(&attr("data-font", "monospace")),
1478 Some(FontFamily::Monospace)
1479 );
1480 // Text colour is the highlight's own vocabulary, read off the same key.
1481 assert_eq!(
1482 MarkColor::from_attrs(&attr("data-color", "blue")),
1483 Some(MarkColor::Blue)
1484 );
1485 }
1486
1487 /// The attribute twig actually writes, read off the shape a `FlatNode`
1488 /// hands over — a `mark` with no colour, one with the colour, and one
1489 /// carrying some other attribute entirely.
1490 #[test]
1491 fn a_colour_is_read_out_of_the_data_color_attribute_and_nothing_else() {
1492 let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
1493 assert_eq!(
1494 MarkColor::from_attrs(&attr("data-color", "green")),
1495 Some(MarkColor::Green)
1496 );
1497 assert_eq!(MarkColor::from_attrs(&[]), None);
1498 assert_eq!(MarkColor::from_attrs(&attr("id", "red")), None);
1499 // A bare attribute has no value to read a colour out of.
1500 assert_eq!(
1501 MarkColor::from_attrs(&[("data-color".to_string(), None)]),
1502 None
1503 );
1504 }
1505
1506 /// Each open type reads the **name first** and the value second, and spells
1507 /// back what it read in one canonical form — so a size set twice from the
1508 /// same field writes the same bytes both times, and a document rewritten by
1509 /// leaf is a document a stylesheet can still key on where a name was used.
1510 #[test]
1511 fn each_property_reads_a_name_or_a_value_and_spells_one_of_them_back() {
1512 // Size: the seven keywords, then `<number>pt`.
1513 let size = |v: &str| FontSize::from_attr(v);
1514 assert_eq!(size("large"), Some(FontSize::Step(SizeStep::Large)));
1515 assert_eq!(size("14pt"), FontSize::points(14.0));
1516 assert_eq!(size("14.0pt"), size("14pt"), "the same size, spelled twice");
1517 assert_eq!(size(" 13.5pt "), FontSize::points(13.5));
1518 assert_eq!(size("14PT"), size("14pt"), "CSS reads its units either way");
1519 assert_eq!(size("14pt").unwrap().name(), "14pt");
1520 assert_eq!(size("13.50pt").unwrap().name(), "13.5pt");
1521 assert_eq!(size("13.25pt").unwrap().name(), "13.25pt");
1522 assert_eq!(size("large").unwrap().name(), "large");
1523 assert_eq!(size("14pt").unwrap().points_value(), Some(14.0));
1524 assert_eq!(size("large").unwrap().points_value(), None);
1525 assert_eq!(size("large").unwrap().step(), Some(SizeStep::Large));
1526
1527 // Line height: the three names, then any positive decimal — and a
1528 // decimal that spells a name *is* the name.
1529 let lh = |v: &str| LineHeight::from_attr(v);
1530 assert_eq!(lh("1.5"), Some(LineHeight::Step(LineSpacing::OneHalf)));
1531 assert_eq!(lh("1.50"), lh("1.5"), "one spacing, not two");
1532 assert_eq!(lh("2.0"), Some(LineHeight::Step(LineSpacing::Double)));
1533 assert_eq!(lh("1.3"), LineHeight::ratio(1.3));
1534 assert_eq!(lh("1.3").unwrap().name(), "1.3");
1535 assert_eq!(lh("1.25").unwrap().name(), "1.25");
1536 assert_eq!(lh("1.5").unwrap().name(), "1.5");
1537 assert!((lh("1.3").unwrap().as_f32() - 1.3).abs() < 1e-6);
1538 assert!((lh("1.5").unwrap().as_f32() - 1.5).abs() < 1e-6);
1539 // Single is absence, however it is spelled, so a gesture given it
1540 // clears the key rather than writing a `data-line-height="1"` that
1541 // says nothing.
1542 for v in ["1", "1.0", "1.00", " 1 "] {
1543 assert_eq!(lh(v), None, "{v:?} is single, which is absence");
1544 }
1545 assert_eq!(LineHeight::ratio(1.0), None);
1546
1547 // Colour: the seven names, `#rrggbb`, and `#rgb` read but never
1548 // written.
1549 let col = |v: &str| TextColor::from_attr(v);
1550 assert_eq!(col("red"), Some(TextColor::Named(MarkColor::Red)));
1551 assert_eq!(
1552 col("#c03030"),
1553 Some(TextColor::Rgb {
1554 r: 0xc0,
1555 g: 0x30,
1556 b: 0x30
1557 })
1558 );
1559 assert_eq!(col("#C03030"), col("#c03030"));
1560 assert_eq!(col("#f00"), col("#ff0000"), "each digit doubled");
1561 assert_eq!(col("#c03030").unwrap().name(), "#c03030");
1562 assert_eq!(col("red").unwrap().name(), "red");
1563 assert_eq!(col("#c03030").unwrap().rgb(), Some((0xc0, 0x30, 0x30)));
1564 assert_eq!(col("red").unwrap().named(), Some(MarkColor::Red));
1565
1566 // Face: the four generics, then any other non-empty string.
1567 let face = |v: &str| FontFace::from_attr(v);
1568 assert_eq!(face("serif"), Some(FontFace::Generic(FontFamily::Serif)));
1569 assert_eq!(face("Garamond"), Some(FontFace::Named("Garamond".into())));
1570 assert_eq!(face(" Garamond "), face("Garamond"));
1571 assert_eq!(face("Garamond").unwrap().name(), "Garamond");
1572 assert_eq!(face("serif").unwrap().name(), "serif");
1573 assert_eq!(face("Garamond").unwrap().generic(), None);
1574 }
1575
1576 /// A value outside the grammar is what it was before the vocabulary opened:
1577 /// `None` here, carried untouched by the document, and drawn at the theme's
1578 /// default. `px` is a screen's unit and a document is not a screen; the
1579 /// relative units are what the steps already are; and a colour function is
1580 /// a CSS parser leaf is not going to become.
1581 #[test]
1582 fn a_value_outside_the_grammar_is_carried_and_not_guessed_at() {
1583 for v in ["huge", "14px", "1.3em", "14", "pt", "-14pt", "0pt", ""] {
1584 assert_eq!(FontSize::from_attr(v), None, "{v:?} is not a size");
1585 }
1586 for v in ["1.3em", "normal", "-1.3", "0", "1.2.3", ""] {
1587 assert_eq!(LineHeight::from_attr(v), None, "{v:?} is not a spacing");
1588 }
1589 for v in [
1590 "rgb(192, 48, 48)",
1591 "chartreuse",
1592 "#c0303",
1593 "#gggggg",
1594 "c03030",
1595 "#",
1596 "",
1597 ] {
1598 assert_eq!(TextColor::from_attr(v), None, "{v:?} is not a colour");
1599 }
1600 // A face has almost no grammar to fall outside of — only emptiness,
1601 // because a family name is whatever the author typed.
1602 assert_eq!(FontFace::from_attr(" "), None);
1603 assert_eq!(FontFace::from_attr(""), None);
1604 // And a bare attribute has no value at all, at every key.
1605 let bare = |k: &str| vec![(k.to_string(), None)];
1606 assert_eq!(FontSize::from_attrs(&bare("data-size")), None);
1607 assert_eq!(LineHeight::from_attrs(&bare("data-line-height")), None);
1608 assert_eq!(TextColor::from_attrs(&bare("data-color")), None);
1609 assert_eq!(FontFace::from_attrs(&bare("data-font")), None);
1610 }
1611
1612 /// The fixed point is the reason a [`Style`] stays `Copy` and `Eq`, and
1613 /// hundredths is where the rounding lands. Pinned because the spelling is
1614 /// what a document is written with: a shortest decimal, so a size set twice
1615 /// from the same field writes the same bytes.
1616 #[test]
1617 fn a_value_is_hundredths_and_spells_itself_as_short_as_it_can() {
1618 let h = |v: f32| Hundredths::from_f32(v).unwrap().to_string();
1619 assert_eq!(h(14.0), "14");
1620 assert_eq!(h(13.5), "13.5");
1621 assert_eq!(h(1.25), "1.25");
1622 assert_eq!(h(1.3), "1.3");
1623 assert_eq!(h(0.05), "0.05");
1624 assert_eq!(Hundredths::from_f32(14.0).unwrap().as_f32(), 14.0);
1625 assert_eq!(Hundredths::from_f32(14.0).unwrap().hundredths(), 1400);
1626 // Out of what a u16 of hundredths holds, and out of what a size means.
1627 assert_eq!(Hundredths::from_f32(700.0), None);
1628 assert_eq!(Hundredths::from_f32(0.0), None);
1629 assert_eq!(Hundredths::from_f32(-1.0), None);
1630 assert_eq!(Hundredths::from_f32(f32::NAN), None);
1631 // And an integer part too long to hold is a `None`, not an overflow.
1632 assert_eq!(FontSize::from_attr("42949672.99pt"), None);
1633 assert_eq!(FontSize::from_attr("999999999999pt"), None);
1634 assert_eq!(FontSize::from_attr("655.36pt"), None);
1635 assert_eq!(FontSize::from_attr("655.35pt").unwrap().name(), "655.35pt");
1636 // A third decimal place rounds rather than being refused — a font panel
1637 // hands back whatever floating point made of its slider.
1638 assert_eq!(FontSize::from_attr("13.456pt"), FontSize::points(13.46));
1639 assert_eq!(FontSize::from_attr("13.454pt"), FontSize::points(13.45));
1640 // CSS wants digits on whichever side of the point the number has: `.5`
1641 // is a number, a trailing bare point is a typo.
1642 assert_eq!(FontSize::from_attr(".5pt"), FontSize::points(0.5));
1643 assert_eq!(LineHeight::from_attr(".5"), LineHeight::ratio(0.5));
1644 assert_eq!(FontSize::from_attr("14.pt"), None);
1645 assert_eq!(LineHeight::from_attr("1."), None);
1646 assert_eq!(FontSize::from_attr(".pt"), None);
1647 }
1648
1649 /// A glyph carries a [`FaceId`], not a `String`, and the id is derived from
1650 /// the name so that a row built three different ways names one face. The
1651 /// table is the only place the string lives.
1652 #[test]
1653 fn a_named_face_is_interned_once_and_its_id_is_the_name_s_own() {
1654 let mut faces = FaceTable::default();
1655 let garamond = FontFace::Named("Garamond".into());
1656 let a = faces.intern(&garamond);
1657 let b = faces.intern(&FontFace::Named("Garamond".into()));
1658 assert_eq!(a, b, "one name, one id");
1659 assert_eq!(faces.len(), 1, "and one entry");
1660 assert_eq!(a, FaceRef::Named(FaceId::of("Garamond")));
1661 assert_eq!(faces.name(FaceId::of("Garamond")), Some("Garamond"));
1662 assert_eq!(a.generic(), None);
1663
1664 // A generic names itself and needs no entry.
1665 let serif = faces.intern(&FontFace::Generic(FontFamily::Serif));
1666 assert_eq!(serif, FaceRef::Generic(FontFamily::Serif));
1667 assert_eq!(serif.id(), None);
1668 assert_eq!(faces.len(), 1);
1669
1670 // Two tables that met the same names in opposite orders are equal, so
1671 // a map assembled out of cache hits compares against a fresh build.
1672 let mut one = FaceTable::default();
1673 one.intern(&FontFace::Named("Futura".into()));
1674 one.intern(&garamond);
1675 let mut two = FaceTable::default();
1676 two.intern(&garamond);
1677 two.intern(&FontFace::Named("Futura".into()));
1678 assert_eq!(one, two);
1679
1680 // And a merge is how a build makes one table out of several walks.
1681 let mut merged = FaceTable::default();
1682 merged.merge(&one);
1683 merged.merge(&faces);
1684 assert_eq!(merged.len(), 2);
1685 assert_eq!(merged.name(FaceId::of("Futura")), Some("Futura"));
1686 assert_eq!(merged.name(FaceId::of("Bodoni")), None);
1687 }
1688
1689 /// `spell` is the one door from what a glyph carries to what a `data-font`
1690 /// says — the line every binding and every native resolver would otherwise
1691 /// write for itself, and all four would have to agree that an id from
1692 /// another map names nothing.
1693 #[test]
1694 fn a_face_table_spells_the_face_a_glyph_carries() {
1695 let mut faces = FaceTable::default();
1696 let garamond = faces.intern(&FontFace::Named("Garamond".into()));
1697 assert_eq!(faces.spell(garamond).as_deref(), Some("Garamond"));
1698 assert_eq!(
1699 faces
1700 .spell(FaceRef::Generic(FontFamily::SansSerif))
1701 .as_deref(),
1702 Some("sans-serif"),
1703 "a generic names itself, and needs no entry to do it"
1704 );
1705 assert_eq!(
1706 faces.spell(FaceRef::Named(FaceId::of("Bodoni"))),
1707 None,
1708 "an id from another map draws in the body face and names nothing"
1709 );
1710 }
1711
1712 /// The one ratio that is absence, told apart from the ratios that are
1713 /// mistakes. `from_attr` answers `None` for both, which is why a binding
1714 /// that must clear the key for the first and refuse the second asks this.
1715 #[test]
1716 fn single_spacing_is_the_one_ratio_that_means_the_theme_s_own() {
1717 for single in ["1", "1.0", "1.00", " 1 "] {
1718 assert!(LineHeight::is_absence(single), "{single}");
1719 assert_eq!(LineHeight::from_attr(single), None, "{single}");
1720 }
1721 for value in ["1.3", "1.5", "2", "0", "0.5", "huge", "1.", "", "1em"] {
1722 assert!(!LineHeight::is_absence(value), "{value}");
1723 }
1724 }
1725}