Skip to main content

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