Skip to main content

hwpforge_core/
numbering.rs

1//! Numbering definitions for outline and list numbering.
2//!
3//! A [`NumberingDef`] contains up to 10 levels of [`ParaHead`] entries,
4//! each defining the number format, prefix/suffix, and display template
5//! for that outline level.
6
7use hwpforge_foundation::{BulletIndex, HeadingType, NumberFormatType, NumberingIndex};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// A single level definition within a numbering scheme.
12///
13/// Maps to HWPX `<hh:paraHead>` inside `<hh:numbering>`.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15pub struct ParaHead {
16    /// Starting number for this level.
17    pub start: u32,
18    /// Outline level (1-10).
19    pub level: u32,
20    /// Number format (DIGIT, HANGUL_SYLLABLE, etc.).
21    pub num_format: NumberFormatType,
22    /// Display template with `^N` placeholder (e.g. `"^1."`, `"(^5)"`).
23    /// Empty string for levels 9 and 10 (self-closing in HWPX).
24    pub text: String,
25    /// Whether this level is checkable.
26    pub checkable: bool,
27}
28
29/// A complete numbering definition.
30///
31/// Maps to HWPX `<hh:numbering>` inside `<hh:numberings>`.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
33pub struct NumberingDef {
34    /// Numbering ID (1-based).
35    pub id: u32,
36    /// Starting number offset.
37    pub start: u32,
38    /// Level definitions (up to 10).
39    pub levels: Vec<ParaHead>,
40}
41
42/// A bullet list definition.
43///
44/// Maps to HWPX `<hh:bullet>` inside `<hh:bullets>`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
46pub struct BulletDef {
47    /// Bullet definition ID (1-based on the wire).
48    pub id: u32,
49    /// Bullet glyph string.
50    pub bullet_char: String,
51    /// Checked bullet glyph string when this bullet is checkable.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub checked_char: Option<String>,
54    /// Whether this bullet uses an image marker.
55    pub use_image: bool,
56    /// Bullet paragraph-head metadata.
57    pub para_head: ParaHead,
58}
59
60/// Shared paragraph list semantics.
61///
62/// This is the format-independent IR carried by paragraph styles. It stores the
63/// resolved list kind plus the branded definition index when a shared numbering
64/// or bullet definition is required.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum ParagraphListRef {
68    /// Outline heading semantics.
69    Outline {
70        /// Zero-based outline level (`0..=9`).
71        level: u8,
72    },
73    /// Numbered list semantics.
74    Number {
75        /// Branded index into the shared numbering definition table.
76        numbering_id: NumberingIndex,
77        /// Zero-based paragraph list level (`0..=9`).
78        level: u8,
79    },
80    /// Bullet list semantics.
81    Bullet {
82        /// Branded index into the shared bullet definition table.
83        bullet_id: BulletIndex,
84        /// Zero-based paragraph list level (`0..=9`).
85        level: u8,
86    },
87    /// Checkable bullet list semantics.
88    CheckBullet {
89        /// Branded index into the shared bullet definition table.
90        bullet_id: BulletIndex,
91        /// Zero-based paragraph list level (`0..=9`).
92        level: u8,
93        /// Whether the checkbox is currently checked.
94        checked: bool,
95    },
96}
97
98impl ParagraphListRef {
99    /// Highest supported shared paragraph list level.
100    pub const MAX_LEVEL: u8 = 9;
101
102    /// Returns the shared list level.
103    pub const fn level(self) -> u8 {
104        match self {
105            Self::Outline { level }
106            | Self::Number { level, .. }
107            | Self::Bullet { level, .. }
108            | Self::CheckBullet { level, .. } => level,
109        }
110    }
111
112    /// Returns the corresponding heading type for HWP-family wire formats.
113    pub const fn heading_type(self) -> HeadingType {
114        match self {
115            Self::Outline { .. } => HeadingType::Outline,
116            Self::Number { .. } => HeadingType::Number,
117            Self::Bullet { .. } | Self::CheckBullet { .. } => HeadingType::Bullet,
118        }
119    }
120
121    /// Returns the checkbox state when this is a checkable bullet paragraph.
122    pub const fn checked(self) -> Option<bool> {
123        match self {
124            Self::CheckBullet { checked, .. } => Some(checked),
125            Self::Outline { .. } | Self::Number { .. } | Self::Bullet { .. } => None,
126        }
127    }
128}
129
130impl NumberingDef {
131    /// Creates the default 10-level outline numbering (한글 Modern default).
132    ///
133    /// Matches golden fixture `tests/fixtures/shapes/textbox.hwpx`:
134    ///
135    /// - Level 1: DIGIT `^1.` checkable=false
136    /// - Level 2: HANGUL_SYLLABLE `^2.` checkable=false
137    /// - Level 3: DIGIT `^3)` checkable=false
138    /// - Level 4: HANGUL_SYLLABLE `^4)` checkable=false
139    /// - Level 5: DIGIT `(^5)` checkable=false
140    /// - Level 6: HANGUL_SYLLABLE `(^6)` checkable=false
141    /// - Level 7: CIRCLED_DIGIT `^7` checkable=true
142    /// - Level 8: CIRCLED_HANGUL_SYLLABLE `^8` checkable=true
143    /// - Level 9: HANGUL_JAMO `` (empty) checkable=false
144    /// - Level 10: ROMAN_SMALL `` (empty) checkable=true
145    pub fn default_outline() -> Self {
146        Self {
147            id: 1,
148            start: 0,
149            levels: vec![
150                ParaHead {
151                    start: 1,
152                    level: 1,
153                    num_format: NumberFormatType::Digit,
154                    text: "^1.".into(),
155                    checkable: false,
156                },
157                ParaHead {
158                    start: 1,
159                    level: 2,
160                    num_format: NumberFormatType::HangulSyllable,
161                    text: "^2.".into(),
162                    checkable: false,
163                },
164                ParaHead {
165                    start: 1,
166                    level: 3,
167                    num_format: NumberFormatType::Digit,
168                    text: "^3)".into(),
169                    checkable: false,
170                },
171                ParaHead {
172                    start: 1,
173                    level: 4,
174                    num_format: NumberFormatType::HangulSyllable,
175                    text: "^4)".into(),
176                    checkable: false,
177                },
178                ParaHead {
179                    start: 1,
180                    level: 5,
181                    num_format: NumberFormatType::Digit,
182                    text: "(^5)".into(),
183                    checkable: false,
184                },
185                ParaHead {
186                    start: 1,
187                    level: 6,
188                    num_format: NumberFormatType::HangulSyllable,
189                    text: "(^6)".into(),
190                    checkable: false,
191                },
192                ParaHead {
193                    start: 1,
194                    level: 7,
195                    num_format: NumberFormatType::CircledDigit,
196                    text: "^7".into(),
197                    checkable: true,
198                },
199                ParaHead {
200                    start: 1,
201                    level: 8,
202                    num_format: NumberFormatType::CircledHangulSyllable,
203                    text: "^8".into(),
204                    checkable: true,
205                },
206                ParaHead {
207                    start: 1,
208                    level: 9,
209                    num_format: NumberFormatType::HangulJamo,
210                    text: String::new(),
211                    checkable: false,
212                },
213                ParaHead {
214                    start: 1,
215                    level: 10,
216                    num_format: NumberFormatType::RomanSmall,
217                    text: String::new(),
218                    checkable: true,
219                },
220            ],
221        }
222    }
223
224    /// Returns the paragraph-head definition for a zero-based shared list level.
225    pub fn para_head(&self, level: u8) -> Option<&ParaHead> {
226        self.levels.get(level as usize)
227    }
228}
229
230impl BulletDef {
231    /// Returns whether this bullet definition can represent checkbox state.
232    pub fn is_checkable(&self) -> bool {
233        self.para_head.checkable || self.checked_char.is_some()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn default_outline_has_10_levels() {
243        let def = NumberingDef::default_outline();
244        assert_eq!(def.levels.len(), 10);
245        assert_eq!(def.id, 1);
246        assert_eq!(def.start, 0);
247    }
248
249    #[test]
250    fn default_outline_level_formats() {
251        let def = NumberingDef::default_outline();
252        assert_eq!(def.levels[0].num_format, NumberFormatType::Digit);
253        assert_eq!(def.levels[1].num_format, NumberFormatType::HangulSyllable);
254        assert_eq!(def.levels[6].num_format, NumberFormatType::CircledDigit);
255        assert_eq!(def.levels[7].num_format, NumberFormatType::CircledHangulSyllable);
256        assert_eq!(def.levels[8].num_format, NumberFormatType::HangulJamo);
257        assert_eq!(def.levels[9].num_format, NumberFormatType::RomanSmall);
258    }
259
260    #[test]
261    fn default_outline_level_texts() {
262        let def = NumberingDef::default_outline();
263        assert_eq!(def.levels[0].text, "^1.");
264        assert_eq!(def.levels[1].text, "^2.");
265        assert_eq!(def.levels[2].text, "^3)");
266        assert_eq!(def.levels[3].text, "^4)");
267        assert_eq!(def.levels[4].text, "(^5)");
268        assert_eq!(def.levels[5].text, "(^6)");
269        assert_eq!(def.levels[6].text, "^7");
270        assert_eq!(def.levels[7].text, "^8");
271        assert_eq!(def.levels[8].text, ""); // self-closing
272        assert_eq!(def.levels[9].text, ""); // self-closing
273    }
274
275    #[test]
276    fn default_outline_checkable_flags() {
277        let def = NumberingDef::default_outline();
278        // Levels 1-6: not checkable
279        for i in 0..6 {
280            assert!(!def.levels[i].checkable, "level {} should not be checkable", i + 1);
281        }
282        // Level 7: checkable
283        assert!(def.levels[6].checkable);
284        // Level 8: checkable
285        assert!(def.levels[7].checkable);
286        // Level 9: NOT checkable
287        assert!(!def.levels[8].checkable);
288        // Level 10: checkable
289        assert!(def.levels[9].checkable);
290    }
291
292    #[test]
293    fn default_outline_levels_are_sequential() {
294        let def = NumberingDef::default_outline();
295        for (i, lvl) in def.levels.iter().enumerate() {
296            assert_eq!(lvl.level, (i + 1) as u32);
297        }
298    }
299}