1use crate::{Paragraph, StyleLookup};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum HeadingSource {
32 ParaShape,
35 Style,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ListSource {
43 ParaShape,
46 StyleName,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ListItemKind {
53 Bullet,
56 Number,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ParaKind {
68 Heading {
70 level: u8,
72 source: HeadingSource,
74 },
75 ListItem {
77 kind: ListItemKind,
79 level: u8,
82 checked: Option<bool>,
84 source: ListSource,
86 },
87 Body,
89}
90
91#[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 #[test]
189 fn noop_styles_and_no_style_id_is_body() {
190 struct Noop;
191 impl StyleLookup for Noop {}
192 assert_eq!(classify_paragraph(¶(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(¶(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(¶(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(¶(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(¶(0), &styles) else {
240 panic!("expected list item");
241 };
242 assert_eq!(level, 0);
243 }
244
245 #[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(¶(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 #[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(¶(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(¶(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 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}