Skip to main content

hwpforge_core/
tab.rs

1//! Tab property definitions.
2//!
3//! Maps to HWPX `<hh:tabProperties>` and `<hh:tabPr>`.
4
5use hwpforge_foundation::{HwpUnit, TabAlign, TabLeader};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// A single explicit tab stop.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11pub struct TabStop {
12    /// Stop position from the paragraph start.
13    pub position: HwpUnit,
14    /// Alignment mode at this stop.
15    pub align: TabAlign,
16    /// Leader style used to fill the gap before the stop.
17    pub leader: TabLeader,
18}
19
20/// A single tab property definition.
21///
22/// Maps to HWPX `<hh:tabPr>`.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24pub struct TabDef {
25    /// Tab property ID (0-based).
26    pub id: u32,
27    /// Auto-insert tab at left margin.
28    pub auto_tab_left: bool,
29    /// Auto-insert tab at right margin.
30    pub auto_tab_right: bool,
31    /// Explicit tab stops.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub stops: Vec<TabStop>,
34}
35
36impl TabDef {
37    /// Number of built-in tab definitions reserved by modern Hancom HWPX.
38    pub const BUILTIN_COUNT: u32 = 3;
39    /// First ID available for user-defined/custom tab definitions.
40    pub const FIRST_CUSTOM_ID: u32 = Self::BUILTIN_COUNT;
41
42    /// Returns the 3 default tab properties (한글 Modern).
43    ///
44    /// Matches golden fixture `tests/fixtures/textbox.hwpx`:
45    ///
46    /// - id=0: no auto tabs (default for most paragraphs)
47    /// - id=1: `autoTabLeft=1` (outline numbering auto-indent)
48    /// - id=2: `autoTabRight=1` (right-aligned tab)
49    pub fn defaults() -> [Self; 3] {
50        [
51            Self { id: 0, auto_tab_left: false, auto_tab_right: false, stops: Vec::new() },
52            Self { id: 1, auto_tab_left: true, auto_tab_right: false, stops: Vec::new() },
53            Self { id: 2, auto_tab_left: false, auto_tab_right: true, stops: Vec::new() },
54        ]
55    }
56
57    /// Returns true when `id` points at a built-in Hancom tab definition.
58    pub fn is_builtin_id(id: u32) -> bool {
59        id < Self::BUILTIN_COUNT
60    }
61
62    /// Returns true when `id` points at a custom/user-defined tab definition.
63    pub fn is_custom_id(id: u32) -> bool {
64        id >= Self::FIRST_CUSTOM_ID
65    }
66
67    /// Returns built-in tab definitions merged with explicit overrides/custom tabs.
68    ///
69    /// Built-in ids `0..=2` are always present in the result. Incoming
70    /// definitions with the same ids override the built-in defaults.
71    pub fn merged_with_defaults<'a>(tabs: impl IntoIterator<Item = &'a Self>) -> Vec<Self> {
72        let mut merged = Self::defaults().to_vec();
73        for tab in tabs {
74            if let Some(existing) = merged.iter_mut().find(|candidate| candidate.id == tab.id) {
75                *existing = tab.clone();
76            } else {
77                merged.push(tab.clone());
78            }
79        }
80        merged.sort_by_key(|tab| tab.id);
81        merged
82    }
83
84    /// Returns true when `id` resolves to either a built-in definition or one
85    /// of the provided custom definition ids.
86    pub fn reference_is_known(id: u32, known_custom_ids: impl IntoIterator<Item = u32>) -> bool {
87        Self::is_builtin_id(id) || known_custom_ids.into_iter().any(|candidate| candidate == id)
88    }
89
90    /// Clamps an unsigned raw tab position into the valid [`HwpUnit`] range.
91    pub fn clamp_position_from_unsigned(raw: u64) -> HwpUnit {
92        let clamped = raw.min(HwpUnit::MAX_VALUE as u64) as i32;
93        HwpUnit::new(clamped).expect("tab stop positions must clamp into valid HwpUnit range")
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn defaults_has_3_entries() {
103        let tabs = TabDef::defaults();
104        assert_eq!(tabs.len(), 3);
105    }
106
107    #[test]
108    fn defaults_ids_sequential() {
109        let tabs = TabDef::defaults();
110        assert_eq!(tabs[0].id, 0);
111        assert_eq!(tabs[1].id, 1);
112        assert_eq!(tabs[2].id, 2);
113    }
114
115    #[test]
116    fn defaults_auto_tab_values() {
117        let tabs = TabDef::defaults();
118        // id=0: no auto tabs
119        assert!(!tabs[0].auto_tab_left);
120        assert!(!tabs[0].auto_tab_right);
121        assert!(tabs[0].stops.is_empty());
122        // id=1: auto tab left
123        assert!(tabs[1].auto_tab_left);
124        assert!(!tabs[1].auto_tab_right);
125        assert!(tabs[1].stops.is_empty());
126        // id=2: auto tab right
127        assert!(!tabs[2].auto_tab_left);
128        assert!(tabs[2].auto_tab_right);
129        assert!(tabs[2].stops.is_empty());
130    }
131
132    #[test]
133    fn builtin_id_helpers_match_defaults_boundary() {
134        assert!(TabDef::is_builtin_id(0));
135        assert!(TabDef::is_builtin_id(2));
136        assert!(!TabDef::is_builtin_id(3));
137        assert!(!TabDef::is_custom_id(2));
138        assert!(TabDef::is_custom_id(3));
139    }
140
141    #[test]
142    fn tab_stop_preserves_position_and_semantics() {
143        let stop = TabStop {
144            position: HwpUnit::new(8000).unwrap(),
145            align: TabAlign::Decimal,
146            leader: TabLeader::dot(),
147        };
148        assert_eq!(stop.position, HwpUnit::new(8000).unwrap());
149        assert_eq!(stop.align, TabAlign::Decimal);
150        assert_eq!(stop.leader.as_hwpx_str(), "DOT");
151    }
152
153    #[test]
154    fn merged_with_defaults_keeps_builtins_and_appends_customs() {
155        let tabs = vec![
156            TabDef {
157                id: 1,
158                auto_tab_left: false,
159                auto_tab_right: false,
160                stops: vec![TabStop {
161                    position: HwpUnit::new(5000).unwrap(),
162                    align: TabAlign::Right,
163                    leader: TabLeader::from_hwpx_str("DASH"),
164                }],
165            },
166            TabDef {
167                id: 3,
168                auto_tab_left: false,
169                auto_tab_right: false,
170                stops: vec![TabStop {
171                    position: HwpUnit::new(7500).unwrap(),
172                    align: TabAlign::Left,
173                    leader: TabLeader::none(),
174                }],
175            },
176        ];
177
178        let merged = TabDef::merged_with_defaults(&tabs);
179
180        assert_eq!(merged.len(), 4);
181        assert_eq!(merged[0].id, 0);
182        assert_eq!(merged[1].id, 1);
183        assert_eq!(merged[2].id, 2);
184        assert_eq!(merged[3].id, 3);
185        assert_eq!(merged[1].stops.len(), 1);
186        assert_eq!(merged[3].stops.len(), 1);
187    }
188
189    #[test]
190    fn reference_is_known_accepts_builtin_and_custom_ids() {
191        assert!(TabDef::reference_is_known(0, []));
192        assert!(TabDef::reference_is_known(3, [3, 5]));
193        assert!(!TabDef::reference_is_known(4, [3, 5]));
194    }
195
196    #[test]
197    fn clamp_position_from_unsigned_caps_large_values() {
198        let clamped = TabDef::clamp_position_from_unsigned(u64::MAX);
199        assert_eq!(clamped, HwpUnit::new(HwpUnit::MAX_VALUE).unwrap());
200    }
201}