Skip to main content

hwpforge_core/
outline.rs

1//! Shared paragraph classification for outline/list semantics.
2//!
3//! Single truth source for "what kind of paragraph is this" across format
4//! encoders and read surfaces. The precedence chain was historically embedded
5//! in the Markdown styled encoder; it is lifted here so every consumer
6//! (Markdown export, document outline, read projections) agrees:
7//!
8//! 1. paragraph-shape outline level ([`StyleLookup::para_heading_level`]) —
9//!    the format-agnostic truth source for headings
10//! 2. paragraph-shape list semantics ([`StyleLookup::para_list_type`] plus
11//!    [`para_list_level`](StyleLookup::para_list_level) /
12//!    [`para_checked_state`](StyleLookup::para_checked_state))
13//! 3. style heading level ([`StyleLookup::style_heading_level`])
14//! 4. style-name list heuristic — Korean style names containing `글머리` or
15//!    `개조` imply a bullet list, `번호` a numbered list. This is a documented
16//!    heuristic inherited from the Markdown encoder and kept intentionally
17//!    narrow; new name patterns need explicit justification.
18//!
19//! [`Paragraph::heading_level`] is **not** consulted: that field carries
20//! titleMark / TOC-marker semantics, which is a different axis from
21//! paragraph-shape outline lists.
22//!
23//! Classification is pure paragraph-shape/style semantics: paragraph text is
24//! never consulted. Renderers keep their own empty-text guards (an empty
25//! heading or list item renders as nothing, but that is a rendering concern).
26
27use crate::{Paragraph, StyleLookup};
28
29/// Which axis produced a [`ParaKind::Heading`] classification.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum HeadingSource {
32    /// Paragraph-shape outline metadata
33    /// ([`StyleLookup::para_heading_level`]) — highest priority.
34    ParaShape,
35    /// Style registry heading level
36    /// ([`StyleLookup::style_heading_level`]) — third priority.
37    Style,
38}
39
40/// Which axis produced a [`ParaKind::ListItem`] classification.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ListSource {
43    /// Paragraph-shape list heading ([`StyleLookup::para_list_type`]) —
44    /// second priority.
45    ParaShape,
46    /// Style-name heuristic (`글머리`/`개조`/`번호`) — lowest priority.
47    StyleName,
48}
49
50/// List marker family of a [`ParaKind::ListItem`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ListItemKind {
53    /// Bullet marker. Checkable bullets stay in this family (gotcha #8 —
54    /// checkable is still `BULLET`); see the `checked` field.
55    Bullet,
56    /// Ordered/numbered marker.
57    Number,
58}
59
60/// Semantic kind of a paragraph as resolved through the shared precedence
61/// chain.
62///
63/// This enum is deliberately exhaustive (no `#[non_exhaustive]`): consumers
64/// match on it to render or project paragraphs, and a new classification kind
65/// must force every consumer to decide its handling at compile time.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ParaKind {
68    /// Outline or style heading.
69    Heading {
70        /// Heading depth, clamped to `1..=6`.
71        level: u8,
72        /// Axis that produced the classification.
73        source: HeadingSource,
74    },
75    /// Ordered / bullet (optionally checkable) list item.
76    ListItem {
77        /// Marker family.
78        kind: ListItemKind,
79        /// Zero-based nesting depth. The style-name fallback carries no
80        /// depth information and always reports `0`.
81        level: u8,
82        /// Checkbox state for checkable bullets; `None` = not checkable.
83        checked: Option<bool>,
84        /// Axis that produced the classification.
85        source: ListSource,
86    },
87    /// Plain body paragraph (no outline/list semantics found).
88    Body,
89}
90
91/// Classifies `paragraph` through the shared outline/list precedence chain.
92///
93/// Unknown [`StyleLookup::para_list_type`] strings degrade to
94/// [`ListItemKind::Bullet`], mirroring the historical Markdown renderer
95/// (only `"NUMBER"` selects the numbered family).
96#[must_use]
97pub fn classify_paragraph(paragraph: &Paragraph, styles: &dyn StyleLookup) -> ParaKind {
98    if let Some(level) = styles.para_heading_level(paragraph.para_shape_id) {
99        return ParaKind::Heading { level: level.clamp(1, 6), source: HeadingSource::ParaShape };
100    }
101
102    if let Some(list_type) = styles.para_list_type(paragraph.para_shape_id) {
103        let kind = if list_type == "NUMBER" { ListItemKind::Number } else { ListItemKind::Bullet };
104        return ParaKind::ListItem {
105            kind,
106            level: styles.para_list_level(paragraph.para_shape_id).unwrap_or(0),
107            checked: styles.para_checked_state(paragraph.para_shape_id),
108            source: ListSource::ParaShape,
109        };
110    }
111
112    if let Some(style_id) = paragraph.style_id {
113        if let Some(level) = styles.style_heading_level(style_id) {
114            return ParaKind::Heading { level: level.clamp(1, 6), source: HeadingSource::Style };
115        }
116        if let Some(name) = styles.style_name(style_id) {
117            if name.contains("글머리") || name.contains("개조") {
118                return ParaKind::ListItem {
119                    kind: ListItemKind::Bullet,
120                    level: 0,
121                    checked: None,
122                    source: ListSource::StyleName,
123                };
124            }
125            if name.contains("번호") {
126                return ParaKind::ListItem {
127                    kind: ListItemKind::Number,
128                    level: 0,
129                    checked: None,
130                    source: ListSource::StyleName,
131                };
132            }
133        }
134    }
135
136    ParaKind::Body
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
143    use std::collections::HashMap;
144
145    #[derive(Default)]
146    struct MockStyles {
147        para_headings: HashMap<usize, u8>,
148        para_list_types: HashMap<usize, &'static str>,
149        para_list_levels: HashMap<usize, u8>,
150        para_checked: HashMap<usize, bool>,
151        style_headings: HashMap<usize, u8>,
152        style_names: HashMap<usize, &'static str>,
153    }
154
155    impl StyleLookup for MockStyles {
156        fn para_heading_level(&self, id: ParaShapeIndex) -> Option<u8> {
157            self.para_headings.get(&id.get()).copied()
158        }
159        fn para_list_type(&self, id: ParaShapeIndex) -> Option<&str> {
160            self.para_list_types.get(&id.get()).copied()
161        }
162        fn para_list_level(&self, id: ParaShapeIndex) -> Option<u8> {
163            self.para_list_levels.get(&id.get()).copied()
164        }
165        fn para_checked_state(&self, id: ParaShapeIndex) -> Option<bool> {
166            self.para_checked.get(&id.get()).copied()
167        }
168        fn style_heading_level(&self, id: StyleIndex) -> Option<u8> {
169            self.style_headings.get(&id.get()).copied()
170        }
171        fn style_name(&self, id: StyleIndex) -> Option<&str> {
172            self.style_names.get(&id.get()).copied()
173        }
174    }
175
176    fn para(shape: usize) -> Paragraph {
177        Paragraph::new(ParaShapeIndex::new(shape))
178    }
179
180    fn styled_para(shape: usize, style: usize) -> Paragraph {
181        let mut p = para(shape);
182        p.style_id = Some(StyleIndex::new(style));
183        p
184    }
185
186    // -- edge cases -------------------------------------------------------
187
188    #[test]
189    fn noop_styles_and_no_style_id_is_body() {
190        struct Noop;
191        impl StyleLookup for Noop {}
192        assert_eq!(classify_paragraph(&para(0), &Noop), ParaKind::Body);
193    }
194
195    #[test]
196    fn para_heading_level_zero_clamps_to_one() {
197        let styles = MockStyles { para_headings: [(0, 0)].into(), ..Default::default() };
198        assert_eq!(
199            classify_paragraph(&para(0), &styles),
200            ParaKind::Heading { level: 1, source: HeadingSource::ParaShape }
201        );
202    }
203
204    #[test]
205    fn para_heading_level_255_clamps_to_six() {
206        let styles = MockStyles { para_headings: [(0, 255)].into(), ..Default::default() };
207        assert_eq!(
208            classify_paragraph(&para(0), &styles),
209            ParaKind::Heading { level: 6, source: HeadingSource::ParaShape }
210        );
211    }
212
213    #[test]
214    fn style_heading_level_clamps_like_para_heading() {
215        let styles = MockStyles { style_headings: [(3, 9)].into(), ..Default::default() };
216        assert_eq!(
217            classify_paragraph(&styled_para(0, 3), &styles),
218            ParaKind::Heading { level: 6, source: HeadingSource::Style }
219        );
220    }
221
222    #[test]
223    fn unknown_list_type_string_degrades_to_bullet() {
224        let styles = MockStyles { para_list_types: [(0, "WEIRD")].into(), ..Default::default() };
225        assert_eq!(
226            classify_paragraph(&para(0), &styles),
227            ParaKind::ListItem {
228                kind: ListItemKind::Bullet,
229                level: 0,
230                checked: None,
231                source: ListSource::ParaShape,
232            }
233        );
234    }
235
236    #[test]
237    fn missing_list_level_defaults_to_zero() {
238        let styles = MockStyles { para_list_types: [(0, "BULLET")].into(), ..Default::default() };
239        let ParaKind::ListItem { level, .. } = classify_paragraph(&para(0), &styles) else {
240            panic!("expected list item");
241        };
242        assert_eq!(level, 0);
243    }
244
245    // -- precedence chain -------------------------------------------------
246
247    #[test]
248    fn para_heading_beats_para_list() {
249        let styles = MockStyles {
250            para_headings: [(0, 2)].into(),
251            para_list_types: [(0, "NUMBER")].into(),
252            ..Default::default()
253        };
254        assert_eq!(
255            classify_paragraph(&para(0), &styles),
256            ParaKind::Heading { level: 2, source: HeadingSource::ParaShape }
257        );
258    }
259
260    #[test]
261    fn para_list_beats_style_heading() {
262        let styles = MockStyles {
263            para_list_types: [(0, "BULLET")].into(),
264            style_headings: [(1, 1)].into(),
265            ..Default::default()
266        };
267        let kind = classify_paragraph(&styled_para(0, 1), &styles);
268        assert!(matches!(kind, ParaKind::ListItem { source: ListSource::ParaShape, .. }));
269    }
270
271    #[test]
272    fn style_heading_beats_style_name_list() {
273        let styles = MockStyles {
274            style_headings: [(1, 3)].into(),
275            style_names: [(1, "번호 개요")].into(),
276            ..Default::default()
277        };
278        assert_eq!(
279            classify_paragraph(&styled_para(0, 1), &styles),
280            ParaKind::Heading { level: 3, source: HeadingSource::Style }
281        );
282    }
283
284    // -- normal cases -----------------------------------------------------
285
286    #[test]
287    fn number_list_type_maps_to_number_kind() {
288        let styles = MockStyles {
289            para_list_types: [(0, "NUMBER")].into(),
290            para_list_levels: [(0, 2)].into(),
291            ..Default::default()
292        };
293        assert_eq!(
294            classify_paragraph(&para(0), &styles),
295            ParaKind::ListItem {
296                kind: ListItemKind::Number,
297                level: 2,
298                checked: None,
299                source: ListSource::ParaShape,
300            }
301        );
302    }
303
304    #[test]
305    fn checkable_bullet_carries_checked_state() {
306        let styles = MockStyles {
307            para_list_types: [(0, "BULLET")].into(),
308            para_checked: [(0, true)].into(),
309            ..Default::default()
310        };
311        assert_eq!(
312            classify_paragraph(&para(0), &styles),
313            ParaKind::ListItem {
314                kind: ListItemKind::Bullet,
315                level: 0,
316                checked: Some(true),
317                source: ListSource::ParaShape,
318            }
319        );
320    }
321
322    #[test]
323    fn style_name_bullet_patterns_classify_as_bullet() {
324        for name in ["글머리표", "개조식 본문"] {
325            let styles = MockStyles { style_names: [(2, name)].into(), ..Default::default() };
326            assert_eq!(
327                classify_paragraph(&styled_para(0, 2), &styles),
328                ParaKind::ListItem {
329                    kind: ListItemKind::Bullet,
330                    level: 0,
331                    checked: None,
332                    source: ListSource::StyleName,
333                },
334                "style name {name:?} should imply a bullet list",
335            );
336        }
337    }
338
339    #[test]
340    fn style_name_number_pattern_classifies_as_number() {
341        let styles =
342            MockStyles { style_names: [(2, "번호 목록")].into(), ..Default::default() };
343        assert_eq!(
344            classify_paragraph(&styled_para(0, 2), &styles),
345            ParaKind::ListItem {
346                kind: ListItemKind::Number,
347                level: 0,
348                checked: None,
349                source: ListSource::StyleName,
350            }
351        );
352    }
353
354    #[test]
355    fn unrecognized_style_name_is_body() {
356        let styles = MockStyles { style_names: [(2, "바탕글")].into(), ..Default::default() };
357        assert_eq!(classify_paragraph(&styled_para(0, 2), &styles), ParaKind::Body);
358    }
359
360    #[test]
361    fn core_heading_level_field_is_ignored() {
362        // Paragraph.heading_level is the titleMark axis, not outline truth.
363        let mut p = para(0);
364        p.heading_level = Some(3);
365        struct Noop;
366        impl StyleLookup for Noop {}
367        assert_eq!(classify_paragraph(&p, &Noop), ParaKind::Body);
368    }
369}