Skip to main content

fmd_math/
style.rs

1//! TeX's four math styles, cramping, and the propagation rules.
2//!
3//! Style propagation is exact TeX (The TeXbook, chapter 17 / appendix G):
4//! scripts go D,T → S → SS with subscripts cramped; fraction interiors go
5//! D → T → S → SS with denominators cramped; radicands and accent bases are
6//! cramped at the current style; radical indices are set in scriptscript
7//! style unconditionally. Glyph sizes follow CM's 10/7/5 pt family:
8//! 1.0 / 0.7 / 0.5.
9//!
10//! [`style_walk`] is the *normative* propagation definition: the layout
11//! stages consume the same walk, and the style-propagation fixtures lock it.
12
13use crate::node::{Node, NodeKind, StackKind};
14
15/// The four math styles (cramped variants ride [`StyleCtx`]).
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum Style {
18    /// Display style.
19    Display,
20    /// Text (inline) style.
21    Text,
22    /// Script style (first-order scripts).
23    Script,
24    /// Scriptscript style (everything deeper).
25    ScriptScript,
26}
27
28impl Style {
29    /// True for the script styles, where medium/thick inter-atom spaces are
30    /// suppressed and `\sum`-class operators stop taking display limits.
31    #[inline(always)]
32    #[must_use]
33    pub const fn is_script(self) -> bool {
34        matches!(self, Self::Script | Self::ScriptScript)
35    }
36
37    /// The glyph-size factor of the style relative to text size (CM's
38    /// 10 pt / 7 pt / 5 pt family).
39    #[inline(always)]
40    #[must_use]
41    pub const fn size_factor(self) -> f64 {
42        match self {
43            Self::Display | Self::Text => 1.0,
44            Self::Script => 0.7,
45            Self::ScriptScript => 0.5,
46        }
47    }
48
49    /// The style of a superscript on an atom in `self`.
50    #[inline(always)]
51    #[must_use]
52    pub const fn sup(self) -> Self {
53        match self {
54            Self::Display | Self::Text => Self::Script,
55            Self::Script | Self::ScriptScript => Self::ScriptScript,
56        }
57    }
58
59    /// The style of a fraction numerator in `self`.
60    #[inline(always)]
61    #[must_use]
62    pub const fn num(self) -> Self {
63        match self {
64            Self::Display => Self::Text,
65            Self::Text => Self::Script,
66            Self::Script | Self::ScriptScript => Self::ScriptScript,
67        }
68    }
69}
70
71/// A style with its cramping state: the full layout context TeX threads
72/// top-down.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
74pub struct StyleCtx {
75    /// The current style.
76    pub style: Style,
77    /// Cramped: superscripts are lowered (interiors of denominators,
78    /// subscripts, radicands, accent bases, …).
79    pub cramped: bool,
80}
81
82impl StyleCtx {
83    /// An uncramped context in the given style.
84    #[inline(always)]
85    #[must_use]
86    pub const fn new(style: Style) -> Self {
87        Self {
88            style,
89            cramped: false,
90        }
91    }
92
93    /// Display, uncramped: the default whole-formula context.
94    #[inline(always)]
95    #[must_use]
96    pub const fn display() -> Self {
97        Self::new(Style::Display)
98    }
99
100    /// The context of a superscript: style goes up one script level,
101    /// cramping is preserved.
102    #[inline(always)]
103    #[must_use]
104    pub const fn sup(self) -> Self {
105        Self {
106            style: self.style.sup(),
107            cramped: self.cramped,
108        }
109    }
110
111    /// The context of a subscript: like [`Self::sup`] but always cramped.
112    #[inline(always)]
113    #[must_use]
114    pub const fn sub(self) -> Self {
115        Self {
116            style: self.style.sup(),
117            cramped: true,
118        }
119    }
120
121    /// The context of a fraction numerator: style goes down one fraction
122    /// level, cramping preserved.
123    #[inline(always)]
124    #[must_use]
125    pub const fn num(self) -> Self {
126        Self {
127            style: self.style.num(),
128            cramped: self.cramped,
129        }
130    }
131
132    /// The context of a fraction denominator: like [`Self::num`] but always
133    /// cramped.
134    #[inline(always)]
135    #[must_use]
136    pub const fn den(self) -> Self {
137        Self {
138            style: self.style.num(),
139            cramped: true,
140        }
141    }
142
143    /// The same style, cramped (radicands, accent bases).
144    #[inline(always)]
145    #[must_use]
146    pub const fn cramp(self) -> Self {
147        Self {
148            style: self.style,
149            cramped: true,
150        }
151    }
152
153    /// Glyph-size factor of the current style.
154    #[inline(always)]
155    #[must_use]
156    pub const fn size_factor(self) -> f64 {
157        self.style.size_factor()
158    }
159}
160
161/// Walk `node` in pre-order, calling `visit` on every node with the style
162/// context it is laid out in. This is the normative propagation definition:
163///
164/// - scripts: base at the current context, superscript at [`StyleCtx::sup`],
165///   subscript at [`StyleCtx::sub`];
166/// - fractions: `\dfrac`/`\tfrac` force the fraction's own effective style
167///   first; numerator at `num`, denominator at `den` of the effective
168///   context;
169/// - radicals: radicand cramped at the current style, index in scriptscript
170///   style (rule 11);
171/// - accents: base cramped;
172/// - `\stackrel`/`\overset` annotations at `sup`, `\underset` at `sub`;
173/// - `\displaystyle`-class markers restyle the remainder of their enclosing
174///   list, preserving cramping;
175/// - `$…$` islands inside text are inline mathematics: they enter at text
176///   style, uncramped;
177/// - environment cells enter at text style (`array`/`matrix`-class and
178///   `cases` set `\textstyle`), except the `align`-class environments,
179///   whose cells keep the ambient context, and `substack`, whose lines
180///   are always set in script style (amsmath's `\subarray`);
181/// - everything else (groups, phantoms, math-font arguments, `\left…\right`
182///   bodies, `\text` islands) inherits the current context.
183pub fn style_walk<'a, F>(node: &'a Node, ctx: StyleCtx, visit: &mut F)
184where
185    F: FnMut(&'a Node, StyleCtx),
186{
187    visit(node, ctx);
188    match &node.kind {
189        NodeKind::List(items) => walk_list(items, ctx, visit),
190        NodeKind::Scripts { base, sub, sup, .. } => {
191            if let Some(b) = base {
192                style_walk(b, ctx, visit);
193            }
194            if let Some(s) = sup {
195                style_walk(s, ctx.sup(), visit);
196            }
197            if let Some(s) = sub {
198                style_walk(s, ctx.sub(), visit);
199            }
200        }
201        NodeKind::Frac { num, den, spec } => {
202            let eff = match spec.forced_style {
203                Some(forced) => StyleCtx {
204                    style: forced,
205                    cramped: ctx.cramped,
206                },
207                None => ctx,
208            };
209            style_walk(num, eff.num(), visit);
210            style_walk(den, eff.den(), visit);
211        }
212        NodeKind::Radical { index, radicand } => {
213            if let Some(ix) = index {
214                style_walk(
215                    ix,
216                    StyleCtx {
217                        style: Style::ScriptScript,
218                        cramped: ctx.cramped,
219                    },
220                    visit,
221                );
222            }
223            style_walk(radicand, ctx.cramp(), visit);
224        }
225        NodeKind::Accent { base, .. } => style_walk(base, ctx.cramp(), visit),
226        NodeKind::LeftRight { body, .. } => walk_list(body, ctx, visit),
227        NodeKind::Text { body } | NodeKind::TextStyled { body, .. } => {
228            walk_list(body, ctx, visit);
229        }
230        NodeKind::MathIsland { body, display } => {
231            let style = if *display {
232                Style::Display
233            } else {
234                Style::Text
235            };
236            walk_list(body, StyleCtx::new(style), visit);
237        }
238        NodeKind::MathFont { body, .. } | NodeKind::Phantom { body, .. } => {
239            style_walk(body, ctx, visit);
240        }
241        NodeKind::Stack {
242            kind,
243            annotation,
244            base,
245        } => {
246            let ann_ctx = match kind {
247                StackKind::Underset => ctx.sub(),
248                StackKind::Stackrel | StackKind::Overset => ctx.sup(),
249            };
250            style_walk(annotation, ann_ctx, visit);
251            style_walk(base, ctx, visit);
252        }
253        NodeKind::XArrow { above, below, .. } => {
254            // amsmath sets both labels in script(-of-current) style, the
255            // above-label uncramped, the below-label cramped like a sub.
256            style_walk(above, ctx.sup(), visit);
257            if let Some(below) = below {
258                style_walk(below, ctx.sub(), visit);
259            }
260        }
261        NodeKind::AlignBlock { lines, .. } => {
262            // Text-mode lines in the ambient style (like a `\text` body).
263            for line in lines {
264                style_walk(line, ctx, visit);
265            }
266        }
267        NodeKind::Environment { name, rows, .. } => {
268            let cell_ctx = if name == "substack" {
269                // amsmath's subarray: lines always in \scriptstyle,
270                // whatever the ambient style.
271                StyleCtx::new(Style::Script)
272            } else if name.starts_with("align") {
273                ctx
274            } else {
275                StyleCtx::new(Style::Text)
276            };
277            for row in rows {
278                for cell in row {
279                    style_walk(cell, cell_ctx, visit);
280                }
281            }
282        }
283        NodeKind::Symbol { .. }
284        | NodeKind::BigOp { .. }
285        | NodeKind::OpName { .. }
286        | NodeKind::SizedDelim { .. }
287        | NodeKind::TextRun { .. }
288        | NodeKind::StyleChange(_)
289        | NodeKind::SizeChange(_)
290        | NodeKind::AlignChange(_)
291        | NodeKind::LineSpacing(_)
292        | NodeKind::ColorChange(_)
293        | NodeKind::Space(_)
294        | NodeKind::Tie
295        | NodeKind::Linebreak
296        | NodeKind::AlignTab
297        | NodeKind::Fragment(_) => {}
298    }
299}
300
301/// Walk a horizontal list, honoring `\displaystyle`-class markers: a
302/// [`NodeKind::StyleChange`] restyles the remainder of the list (cramping
303/// preserved), exactly like TeX's style primitives.
304fn walk_list<'a, F>(items: &'a [Node], mut ctx: StyleCtx, visit: &mut F)
305where
306    F: FnMut(&'a Node, StyleCtx),
307{
308    for item in items {
309        if let NodeKind::StyleChange(style) = &item.kind {
310            visit(item, ctx);
311            ctx = StyleCtx {
312                style: *style,
313                cramped: ctx.cramped,
314            };
315            continue;
316        }
317        style_walk(item, ctx, visit);
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn sup_chain() {
327        assert_eq!(Style::Display.sup(), Style::Script);
328        assert_eq!(Style::Text.sup(), Style::Script);
329        assert_eq!(Style::Script.sup(), Style::ScriptScript);
330        assert_eq!(Style::ScriptScript.sup(), Style::ScriptScript);
331    }
332
333    #[test]
334    fn num_chain() {
335        assert_eq!(Style::Display.num(), Style::Text);
336        assert_eq!(Style::Text.num(), Style::Script);
337        assert_eq!(Style::Script.num(), Style::ScriptScript);
338        assert_eq!(Style::ScriptScript.num(), Style::ScriptScript);
339    }
340
341    #[test]
342    fn sub_is_cramped_sup() {
343        let ctx = StyleCtx::display();
344        assert_eq!(ctx.sub().style, Style::Script);
345        assert!(ctx.sub().cramped);
346        assert!(!ctx.sup().cramped);
347    }
348
349    #[test]
350    fn den_is_cramped_num() {
351        let ctx = StyleCtx::new(Style::Text);
352        assert_eq!(ctx.den().style, Style::Script);
353        assert!(ctx.den().cramped);
354    }
355
356    #[test]
357    fn size_factors_are_cm_10_7_5() {
358        assert_eq!(Style::Display.size_factor(), 1.0);
359        assert_eq!(Style::Text.size_factor(), 1.0);
360        assert_eq!(Style::Script.size_factor(), 0.7);
361        assert_eq!(Style::ScriptScript.size_factor(), 0.5);
362    }
363}