Skip to main content

docspec_core/
style.rs

1//! Stack of open inline text-style spans with deferred starts and
2//! overlap normalization.
3//!
4//! Readers use [`StyleStack`] to translate format-specific inline-style
5//! information (HTML tags, OOXML run properties, etc.) into the well-formed
6//! [`Event::StartTextStyle`] / [`Event::EndTextStyle`] sequence required by
7//! the event protocol. The stack preserves four invariants from the
8//! [`event`](crate::event) module docs:
9//!
10//! - **Rule 9** — `StartTextStyle` spans nest but never overlap. The stack
11//!   normalizes overlaps via close-and-reopen.
12//! - **Rule 10** — All open style spans close before enclosing block-end
13//!   events. Callers invoke [`StyleStack::close_all`] at block boundaries.
14//! - **Rule 13** — Empty `StartTextStyle` spans are never emitted. Starts
15//!   are deferred at [`StyleStack::open`] and only released by
16//!   [`StyleStack::note_text`] when text is about to be written.
17//! - The stack is bounded by [`MAX_STYLE_DEPTH`]; opens beyond that depth
18//!   are silently ignored so malformed or adversarial input cannot inflate
19//!   reader memory.
20//!
21//! The stack is keyed on [`TextStyleKind`]. Colour-bearing variants such
22//! as [`TextStyleKind::Mark`] and [`TextStyleKind::TextColor`] participate
23//! in equality by colour, so a run that flips highlight colour produces
24//! two distinct style spans, as expected.
25
26use alloc::vec::Vec;
27
28use crate::{Event, TextStyleKind};
29
30/// Maximum number of simultaneously-open inline-style spans.
31///
32/// Beyond this depth, additional [`StyleStack::open`] calls are silently
33/// ignored. This bounds reader memory under adversarial or pathologically
34/// nested input. The limit matches the historical HTML-style stack used
35/// in the markdown reader.
36pub const MAX_STYLE_DEPTH: usize = 32;
37
38/// One open inline style and whether text has been emitted since it opened.
39#[derive(Debug, Clone)]
40struct StyleFrame {
41    kind: TextStyleKind,
42    text_emitted: bool,
43}
44
45/// Stack of open inline-style spans with deferred starts and overlap
46/// normalization.
47///
48/// Construct with [`StyleStack::default`]. The typical reader loop is:
49///
50/// 1. On encountering an open-style marker, call [`StyleStack::open`] and
51///    enqueue the returned events.
52/// 2. Before emitting any [`Event::Text`], call [`StyleStack::note_text`]
53///    and enqueue the returned events (deferred `StartTextStyle` events
54///    are released here).
55/// 3. On encountering a close-style marker, call [`StyleStack::close`].
56/// 4. At a block boundary, call [`StyleStack::close_all`] to auto-close
57///    any spans still open.
58///
59/// All four methods return the events the reader should emit, in order,
60/// for the current step. The stack itself never emits directly.
61#[derive(Debug, Clone, Default)]
62pub struct StyleStack {
63    frames: Vec<StyleFrame>,
64    deferred_starts: Vec<Event>,
65}
66
67impl StyleStack {
68    /// Opens an inline style if it is not already active and depth allows it.
69    ///
70    /// The corresponding [`Event::StartTextStyle`] is **deferred** — it is
71    /// not returned here. It is released by the next [`StyleStack::note_text`]
72    /// call, so an empty styled span (open immediately followed by close
73    /// with no intervening text) emits no events. This implements Rule 13.
74    ///
75    /// Opens are idempotent on `kind`: if a frame with an equal kind is
76    /// already on the stack, this call is a no-op and returns an empty
77    /// vector. Colour-bearing variants compare by colour, so opening
78    /// `Mark(red)` while `Mark(blue)` is open does push a new frame.
79    ///
80    /// Returns an empty vector in all cases; the signature returns
81    /// `Vec<Event>` to match [`StyleStack::close`] and
82    /// [`StyleStack::note_text`] for caller uniformity.
83    #[inline]
84    pub fn open(&mut self, kind: TextStyleKind) -> Vec<Event> {
85        if self.frames.iter().any(|frame| frame.kind == kind)
86            || self.frames.len() >= MAX_STYLE_DEPTH
87        {
88            return Vec::new();
89        }
90
91        let start = Event::StartTextStyle {
92            kind: kind.clone(),
93            id: None,
94        };
95        self.frames.push(StyleFrame {
96            kind,
97            text_emitted: false,
98        });
99        self.deferred_starts.push(start);
100        Vec::new()
101    }
102
103    /// Closes an inline style, normalizing overlaps via close-and-reopen.
104    ///
105    /// If `kind` is not currently open, the call is a no-op and returns
106    /// an empty vector. When the matching frame is below other open
107    /// frames in the stack (an overlap such as `<b><i>x</b></i>`), the
108    /// frames above it are closed in LIFO order, the target frame is
109    /// closed, and the previously-above frames are re-opened with their
110    /// starts deferred again. This is the close-and-reopen pattern that
111    /// satisfies Rule 9.
112    ///
113    /// Returns the [`Event::EndTextStyle`] events that the caller should
114    /// emit, in order. A frame whose `text_emitted` flag is `false`
115    /// contributes no event — its deferred start was never released, so
116    /// no matching end is needed (Rule 13).
117    #[inline]
118    pub fn close(&mut self, kind: &TextStyleKind) -> Vec<Event> {
119        let Some(position) = self.frames.iter().rposition(|frame| frame.kind == *kind) else {
120            return Vec::new();
121        };
122        let Some(after_position) = position.checked_add(1) else {
123            return Vec::new();
124        };
125
126        if after_position == self.frames.len() {
127            let Some(frame) = self.frames.pop() else {
128                return Vec::new();
129            };
130            self.rebuild_deferred_starts();
131            return if frame.text_emitted {
132                alloc::vec![Event::EndTextStyle]
133            } else {
134                Vec::new()
135            };
136        }
137
138        let mut emitted = Vec::new();
139        let mut above = self.frames.split_off(after_position);
140        for frame in above.iter().rev() {
141            if frame.text_emitted {
142                emitted.push(Event::EndTextStyle);
143            }
144        }
145
146        let Some(matched) = self.frames.pop() else {
147            self.rebuild_deferred_starts();
148            return emitted;
149        };
150        if matched.text_emitted {
151            emitted.push(Event::EndTextStyle);
152        }
153
154        for frame in above.drain(..) {
155            self.frames.push(StyleFrame {
156                kind: frame.kind,
157                text_emitted: false,
158            });
159        }
160        self.rebuild_deferred_starts();
161
162        emitted
163    }
164
165    /// Marks every open frame as having emitted text and releases all
166    /// deferred [`Event::StartTextStyle`] events.
167    ///
168    /// Callers invoke this immediately before emitting an
169    /// [`Event::Text`], [`Event::LineBreak`], or any other event that
170    /// constitutes "content under the currently open styles". The
171    /// returned events must be enqueued in order, before the content
172    /// event.
173    #[inline]
174    pub fn note_text(&mut self) -> Vec<Event> {
175        for frame in &mut self.frames {
176            frame.text_emitted = true;
177        }
178        self.deferred_starts.drain(..).collect()
179    }
180
181    /// Closes every active style from innermost to outermost, suppressing
182    /// frames whose deferred start was never released.
183    ///
184    /// Used at block boundaries (paragraph end, heading end, run end in
185    /// OOXML, etc.) to satisfy Rule 10. After this call the stack is
186    /// empty.
187    #[inline]
188    pub fn close_all(&mut self) -> Vec<Event> {
189        let mut emitted = Vec::new();
190        for frame in self.frames.iter().rev() {
191            if frame.text_emitted {
192                emitted.push(Event::EndTextStyle);
193            }
194        }
195        self.frames.clear();
196        self.deferred_starts.clear();
197        emitted
198    }
199
200    /// Returns `true` when no open frames and no deferred starts remain.
201    #[inline]
202    #[must_use]
203    pub fn is_empty(&self) -> bool {
204        self.frames.is_empty() && self.deferred_starts.is_empty()
205    }
206
207    fn rebuild_deferred_starts(&mut self) {
208        self.deferred_starts = self
209            .frames
210            .iter()
211            .filter(|frame| !frame.text_emitted)
212            .map(|frame| Event::StartTextStyle {
213                kind: frame.kind.clone(),
214                id: None,
215            })
216            .collect();
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::Color;
224    use alloc::vec;
225
226    fn start(kind: TextStyleKind) -> Event {
227        Event::StartTextStyle { kind, id: None }
228    }
229
230    fn yellow() -> Color {
231        Color::Rgb {
232            r: 255,
233            g: 255,
234            b: 0,
235        }
236    }
237
238    // LOAD-BEARING: changing this constant is a behavior-visible change for
239    // every reader that uses StyleStack (markdown HTML translation, docx
240    // run-property parsing, etc.).
241    #[test]
242    fn max_style_depth_is_32() {
243        assert_eq!(MAX_STYLE_DEPTH, 32);
244    }
245
246    fn blue() -> Color {
247        Color::Rgb { r: 0, g: 0, b: 255 }
248    }
249
250    fn red() -> Color {
251        Color::Rgb { r: 255, g: 0, b: 0 }
252    }
253
254    #[test]
255    fn open_then_close_with_text() {
256        let mut stack = StyleStack::default();
257
258        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
259        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Bold)]);
260        assert_eq!(stack.close(&TextStyleKind::Bold), vec![Event::EndTextStyle]);
261        assert!(stack.is_empty());
262    }
263
264    #[test]
265    fn open_then_close_without_text_emits_nothing() {
266        let mut stack = StyleStack::default();
267
268        assert_eq!(stack.open(TextStyleKind::Italic), Vec::new());
269        assert_eq!(stack.close(&TextStyleKind::Italic), Vec::new());
270        assert!(stack.is_empty());
271    }
272
273    #[test]
274    fn same_kind_nesting_idempotent() {
275        let mut stack = StyleStack::default();
276
277        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
278        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
279        assert_eq!(stack.frames.len(), 1);
280        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Bold)]);
281        assert_eq!(stack.close(&TextStyleKind::Bold), vec![Event::EndTextStyle]);
282        assert_eq!(stack.close(&TextStyleKind::Bold), Vec::new());
283        assert!(stack.is_empty());
284    }
285
286    #[test]
287    fn rule_9_mismatched_closers_with_text() {
288        let mut stack = StyleStack::default();
289
290        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
291        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Bold)]);
292        assert_eq!(stack.open(TextStyleKind::Italic), Vec::new());
293        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Italic)]);
294
295        assert_eq!(
296            stack.close(&TextStyleKind::Bold),
297            vec![Event::EndTextStyle, Event::EndTextStyle]
298        );
299        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Italic)]);
300        assert_eq!(
301            stack.close(&TextStyleKind::Italic),
302            vec![Event::EndTextStyle]
303        );
304        assert!(stack.is_empty());
305    }
306
307    #[test]
308    fn rule_9_mismatched_closers_no_extra_text() {
309        let mut stack = StyleStack::default();
310
311        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
312        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Bold)]);
313        assert_eq!(stack.open(TextStyleKind::Italic), Vec::new());
314        assert_eq!(stack.note_text(), vec![start(TextStyleKind::Italic)]);
315        assert_eq!(
316            stack.close(&TextStyleKind::Bold),
317            vec![Event::EndTextStyle, Event::EndTextStyle]
318        );
319
320        assert_eq!(stack.close(&TextStyleKind::Italic), Vec::new());
321        assert!(stack.is_empty());
322    }
323
324    #[test]
325    fn depth_bound() {
326        let mut stack = StyleStack::default();
327
328        // Mark with distinct colours produces distinct kinds, so we can
329        // fill the stack with MAX_STYLE_DEPTH frames using one variant.
330        for level in 0..MAX_STYLE_DEPTH {
331            let level_u8 = u8::try_from(level).unwrap_or(u8::MAX);
332            assert_eq!(
333                stack.open(TextStyleKind::Mark(Color::Rgb {
334                    r: level_u8,
335                    g: 0,
336                    b: 0,
337                })),
338                Vec::new()
339            );
340        }
341        // One more open beyond MAX_STYLE_DEPTH is silently ignored.
342        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
343
344        assert_eq!(stack.frames.len(), MAX_STYLE_DEPTH);
345        assert_eq!(stack.deferred_starts.len(), MAX_STYLE_DEPTH);
346    }
347
348    #[test]
349    fn close_unmatched() {
350        let mut stack = StyleStack::default();
351
352        assert_eq!(stack.close(&TextStyleKind::Bold), Vec::new());
353        assert!(stack.is_empty());
354    }
355
356    #[test]
357    fn close_all_with_open_frames() {
358        let mut stack = StyleStack::default();
359
360        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
361        assert_eq!(stack.open(TextStyleKind::Italic), Vec::new());
362        assert_eq!(
363            stack.note_text(),
364            vec![start(TextStyleKind::Bold), start(TextStyleKind::Italic)]
365        );
366        assert_eq!(
367            stack.close_all(),
368            vec![Event::EndTextStyle, Event::EndTextStyle]
369        );
370        assert!(stack.is_empty());
371    }
372
373    #[test]
374    fn close_all_with_deferred_only_emits_nothing() {
375        let mut stack = StyleStack::default();
376
377        assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
378        assert_eq!(stack.open(TextStyleKind::Italic), Vec::new());
379        assert_eq!(stack.close_all(), Vec::new());
380        assert!(stack.is_empty());
381    }
382
383    #[test]
384    fn mark_with_arbitrary_color_round_trips() {
385        let mut stack = StyleStack::default();
386
387        assert_eq!(stack.open(TextStyleKind::Mark(yellow())), Vec::new());
388        assert_eq!(
389            stack.note_text(),
390            vec![start(TextStyleKind::Mark(yellow()))]
391        );
392        assert_eq!(
393            stack.close(&TextStyleKind::Mark(yellow())),
394            vec![Event::EndTextStyle]
395        );
396        assert!(stack.is_empty());
397    }
398
399    #[test]
400    fn distinct_mark_colors_are_distinct_kinds() {
401        // Opening Mark(red) when Mark(blue) is already open must push
402        // a second frame, because PartialEq on TextStyleKind compares
403        // colour. This is the docx use case: adjacent runs with
404        // different highlight colours each get their own span.
405        let mut stack = StyleStack::default();
406
407        assert_eq!(stack.open(TextStyleKind::Mark(blue())), Vec::new());
408        assert_eq!(stack.open(TextStyleKind::Mark(red())), Vec::new());
409        assert_eq!(stack.frames.len(), 2);
410        assert_eq!(
411            stack.note_text(),
412            vec![
413                start(TextStyleKind::Mark(blue())),
414                start(TextStyleKind::Mark(red()))
415            ]
416        );
417        assert_eq!(
418            stack.close(&TextStyleKind::Mark(red())),
419            vec![Event::EndTextStyle]
420        );
421        assert_eq!(
422            stack.close(&TextStyleKind::Mark(blue())),
423            vec![Event::EndTextStyle]
424        );
425        assert!(stack.is_empty());
426    }
427
428    #[test]
429    fn text_color_round_trips() {
430        let mut stack = StyleStack::default();
431
432        assert_eq!(stack.open(TextStyleKind::TextColor(red())), Vec::new());
433        assert_eq!(
434            stack.note_text(),
435            vec![start(TextStyleKind::TextColor(red()))]
436        );
437        assert_eq!(
438            stack.close(&TextStyleKind::TextColor(red())),
439            vec![Event::EndTextStyle]
440        );
441        assert!(stack.is_empty());
442    }
443
444    #[test]
445    fn adversarial_repeated_open_close_is_bounded() {
446        let mut stack = StyleStack::default();
447
448        for _ in 0..10_000 {
449            assert_eq!(stack.open(TextStyleKind::Bold), Vec::new());
450        }
451        assert_eq!(stack.frames.len(), 1);
452
453        for _ in 0..10_000 {
454            let _events = stack.close(&TextStyleKind::Bold);
455        }
456        assert!(stack.is_empty());
457    }
458}