use crate::{Paragraph, StyleLookup};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingSource {
ParaShape,
Style,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListSource {
ParaShape,
StyleName,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListItemKind {
Bullet,
Number,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParaKind {
Heading {
level: u8,
source: HeadingSource,
},
ListItem {
kind: ListItemKind,
level: u8,
checked: Option<bool>,
source: ListSource,
},
Body,
}
#[must_use]
pub fn classify_paragraph(paragraph: &Paragraph, styles: &dyn StyleLookup) -> ParaKind {
if let Some(level) = styles.para_heading_level(paragraph.para_shape_id) {
return ParaKind::Heading { level: level.clamp(1, 6), source: HeadingSource::ParaShape };
}
if let Some(list_type) = styles.para_list_type(paragraph.para_shape_id) {
let kind = if list_type == "NUMBER" { ListItemKind::Number } else { ListItemKind::Bullet };
return ParaKind::ListItem {
kind,
level: styles.para_list_level(paragraph.para_shape_id).unwrap_or(0),
checked: styles.para_checked_state(paragraph.para_shape_id),
source: ListSource::ParaShape,
};
}
if let Some(style_id) = paragraph.style_id {
if let Some(level) = styles.style_heading_level(style_id) {
return ParaKind::Heading { level: level.clamp(1, 6), source: HeadingSource::Style };
}
if let Some(name) = styles.style_name(style_id) {
if name.contains("글머리") || name.contains("개조") {
return ParaKind::ListItem {
kind: ListItemKind::Bullet,
level: 0,
checked: None,
source: ListSource::StyleName,
};
}
if name.contains("번호") {
return ParaKind::ListItem {
kind: ListItemKind::Number,
level: 0,
checked: None,
source: ListSource::StyleName,
};
}
}
}
ParaKind::Body
}
#[cfg(test)]
mod tests {
use super::*;
use hwpforge_foundation::{ParaShapeIndex, StyleIndex};
use std::collections::HashMap;
#[derive(Default)]
struct MockStyles {
para_headings: HashMap<usize, u8>,
para_list_types: HashMap<usize, &'static str>,
para_list_levels: HashMap<usize, u8>,
para_checked: HashMap<usize, bool>,
style_headings: HashMap<usize, u8>,
style_names: HashMap<usize, &'static str>,
}
impl StyleLookup for MockStyles {
fn para_heading_level(&self, id: ParaShapeIndex) -> Option<u8> {
self.para_headings.get(&id.get()).copied()
}
fn para_list_type(&self, id: ParaShapeIndex) -> Option<&str> {
self.para_list_types.get(&id.get()).copied()
}
fn para_list_level(&self, id: ParaShapeIndex) -> Option<u8> {
self.para_list_levels.get(&id.get()).copied()
}
fn para_checked_state(&self, id: ParaShapeIndex) -> Option<bool> {
self.para_checked.get(&id.get()).copied()
}
fn style_heading_level(&self, id: StyleIndex) -> Option<u8> {
self.style_headings.get(&id.get()).copied()
}
fn style_name(&self, id: StyleIndex) -> Option<&str> {
self.style_names.get(&id.get()).copied()
}
}
fn para(shape: usize) -> Paragraph {
Paragraph::new(ParaShapeIndex::new(shape))
}
fn styled_para(shape: usize, style: usize) -> Paragraph {
let mut p = para(shape);
p.style_id = Some(StyleIndex::new(style));
p
}
#[test]
fn noop_styles_and_no_style_id_is_body() {
struct Noop;
impl StyleLookup for Noop {}
assert_eq!(classify_paragraph(¶(0), &Noop), ParaKind::Body);
}
#[test]
fn para_heading_level_zero_clamps_to_one() {
let styles = MockStyles { para_headings: [(0, 0)].into(), ..Default::default() };
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::Heading { level: 1, source: HeadingSource::ParaShape }
);
}
#[test]
fn para_heading_level_255_clamps_to_six() {
let styles = MockStyles { para_headings: [(0, 255)].into(), ..Default::default() };
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::Heading { level: 6, source: HeadingSource::ParaShape }
);
}
#[test]
fn style_heading_level_clamps_like_para_heading() {
let styles = MockStyles { style_headings: [(3, 9)].into(), ..Default::default() };
assert_eq!(
classify_paragraph(&styled_para(0, 3), &styles),
ParaKind::Heading { level: 6, source: HeadingSource::Style }
);
}
#[test]
fn unknown_list_type_string_degrades_to_bullet() {
let styles = MockStyles { para_list_types: [(0, "WEIRD")].into(), ..Default::default() };
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::ListItem {
kind: ListItemKind::Bullet,
level: 0,
checked: None,
source: ListSource::ParaShape,
}
);
}
#[test]
fn missing_list_level_defaults_to_zero() {
let styles = MockStyles { para_list_types: [(0, "BULLET")].into(), ..Default::default() };
let ParaKind::ListItem { level, .. } = classify_paragraph(¶(0), &styles) else {
panic!("expected list item");
};
assert_eq!(level, 0);
}
#[test]
fn para_heading_beats_para_list() {
let styles = MockStyles {
para_headings: [(0, 2)].into(),
para_list_types: [(0, "NUMBER")].into(),
..Default::default()
};
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::Heading { level: 2, source: HeadingSource::ParaShape }
);
}
#[test]
fn para_list_beats_style_heading() {
let styles = MockStyles {
para_list_types: [(0, "BULLET")].into(),
style_headings: [(1, 1)].into(),
..Default::default()
};
let kind = classify_paragraph(&styled_para(0, 1), &styles);
assert!(matches!(kind, ParaKind::ListItem { source: ListSource::ParaShape, .. }));
}
#[test]
fn style_heading_beats_style_name_list() {
let styles = MockStyles {
style_headings: [(1, 3)].into(),
style_names: [(1, "번호 개요")].into(),
..Default::default()
};
assert_eq!(
classify_paragraph(&styled_para(0, 1), &styles),
ParaKind::Heading { level: 3, source: HeadingSource::Style }
);
}
#[test]
fn number_list_type_maps_to_number_kind() {
let styles = MockStyles {
para_list_types: [(0, "NUMBER")].into(),
para_list_levels: [(0, 2)].into(),
..Default::default()
};
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::ListItem {
kind: ListItemKind::Number,
level: 2,
checked: None,
source: ListSource::ParaShape,
}
);
}
#[test]
fn checkable_bullet_carries_checked_state() {
let styles = MockStyles {
para_list_types: [(0, "BULLET")].into(),
para_checked: [(0, true)].into(),
..Default::default()
};
assert_eq!(
classify_paragraph(¶(0), &styles),
ParaKind::ListItem {
kind: ListItemKind::Bullet,
level: 0,
checked: Some(true),
source: ListSource::ParaShape,
}
);
}
#[test]
fn style_name_bullet_patterns_classify_as_bullet() {
for name in ["글머리표", "개조식 본문"] {
let styles = MockStyles { style_names: [(2, name)].into(), ..Default::default() };
assert_eq!(
classify_paragraph(&styled_para(0, 2), &styles),
ParaKind::ListItem {
kind: ListItemKind::Bullet,
level: 0,
checked: None,
source: ListSource::StyleName,
},
"style name {name:?} should imply a bullet list",
);
}
}
#[test]
fn style_name_number_pattern_classifies_as_number() {
let styles =
MockStyles { style_names: [(2, "번호 목록")].into(), ..Default::default() };
assert_eq!(
classify_paragraph(&styled_para(0, 2), &styles),
ParaKind::ListItem {
kind: ListItemKind::Number,
level: 0,
checked: None,
source: ListSource::StyleName,
}
);
}
#[test]
fn unrecognized_style_name_is_body() {
let styles = MockStyles { style_names: [(2, "바탕글")].into(), ..Default::default() };
assert_eq!(classify_paragraph(&styled_para(0, 2), &styles), ParaKind::Body);
}
#[test]
fn core_heading_level_field_is_ignored() {
let mut p = para(0);
p.heading_level = Some(3);
struct Noop;
impl StyleLookup for Noop {}
assert_eq!(classify_paragraph(&p, &Noop), ParaKind::Body);
}
}