use hwpforge_core::{BulletDef, NumberingDef, ParagraphListRef};
use hwpforge_foundation::{
Alignment, BorderFillIndex, BreakType, Color, EmbossType, EmphasisType, EngraveType,
HeadingType, HwpUnit, LineSpacingType, OutlineType, ShadowType, StrikeoutShape, UnderlineType,
VerticalPosition,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::{BlueprintError, BlueprintResult};
use crate::serde_helpers::{
de_color, de_color_opt, de_dim, de_dim_opt, de_pct_opt, ser_color, ser_color_opt, ser_dim,
ser_dim_opt, ser_pct_opt,
};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Spacing {
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub before: Option<HwpUnit>,
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub after: Option<HwpUnit>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Indent {
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub left: Option<HwpUnit>,
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub right: Option<HwpUnit>,
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub first_line: Option<HwpUnit>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct LineSpacing {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing_type: Option<LineSpacingType>,
#[serde(
default,
serialize_with = "ser_pct_opt",
deserialize_with = "de_pct_opt",
skip_serializing_if = "Option::is_none"
)]
pub value: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct PartialCharShape {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
#[serde(
default,
serialize_with = "ser_dim_opt",
deserialize_with = "de_dim_opt",
skip_serializing_if = "Option::is_none"
)]
pub size: Option<HwpUnit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bold: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub italic: Option<bool>,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline_type: Option<UnderlineType>,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub underline_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strikeout_shape: Option<StrikeoutShape>,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub strikeout_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outline: Option<OutlineType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shadow: Option<ShadowType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emboss: Option<EmbossType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engrave: Option<EngraveType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vertical_position: Option<VerticalPosition>,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub shade_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emphasis: Option<EmphasisType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rel_sz: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_kerning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_font_space: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_border_fill_id: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PartialParagraphListRef {
Outline {
level: u8,
},
Number {
numbering_id: u32,
level: u8,
},
Bullet {
bullet_id: u32,
level: u8,
},
CheckBullet {
bullet_id: u32,
level: u8,
checked: bool,
},
}
impl PartialParagraphListRef {
fn resolve(
self,
style_name: &str,
numberings: &[NumberingDef],
bullets: &[BulletDef],
) -> BlueprintResult<ParagraphListRef> {
let level = match self {
Self::Outline { level }
| Self::Number { level, .. }
| Self::Bullet { level, .. }
| Self::CheckBullet { level, .. } => level,
};
if level > ParagraphListRef::MAX_LEVEL {
return Err(BlueprintError::InvalidListLevel {
style_name: style_name.to_string(),
level,
max: ParagraphListRef::MAX_LEVEL,
});
}
match self {
Self::Outline { level } => Ok(ParagraphListRef::Outline { level }),
Self::Number { numbering_id, level } => {
let numbering_index = numberings
.iter()
.position(|numbering| numbering.id == numbering_id)
.ok_or_else(|| BlueprintError::InvalidListReference {
style_name: style_name.to_string(),
kind: "numbering".to_string(),
id: numbering_id,
})?;
Ok(ParagraphListRef::Number {
numbering_id: hwpforge_foundation::NumberingIndex::new(numbering_index),
level,
})
}
Self::Bullet { bullet_id, level } => {
let bullet_index = bullets
.iter()
.position(|bullet| bullet.id == bullet_id)
.ok_or_else(|| BlueprintError::InvalidListReference {
style_name: style_name.to_string(),
kind: "bullet".to_string(),
id: bullet_id,
})?;
let bullet = &bullets[bullet_index];
if bullet.is_checkable() {
return Err(BlueprintError::InvalidPlainBulletDefinition {
style_name: style_name.to_string(),
bullet_id,
});
}
Ok(ParagraphListRef::Bullet {
bullet_id: hwpforge_foundation::BulletIndex::new(bullet_index),
level,
})
}
Self::CheckBullet { bullet_id, level, checked } => {
let bullet_index = bullets
.iter()
.position(|bullet| bullet.id == bullet_id)
.ok_or_else(|| BlueprintError::InvalidListReference {
style_name: style_name.to_string(),
kind: "bullet".to_string(),
id: bullet_id,
})?;
let bullet = &bullets[bullet_index];
if !bullet.is_checkable() {
return Err(BlueprintError::InvalidCheckableBulletDefinition {
style_name: style_name.to_string(),
bullet_id,
});
}
Ok(ParagraphListRef::CheckBullet {
bullet_id: hwpforge_foundation::BulletIndex::new(bullet_index),
level,
checked,
})
}
}
}
}
impl PartialCharShape {
pub fn merge(&mut self, other: &PartialCharShape) {
if other.font.is_some() {
self.font.clone_from(&other.font);
}
if other.size.is_some() {
self.size = other.size;
}
if other.bold.is_some() {
self.bold = other.bold;
}
if other.italic.is_some() {
self.italic = other.italic;
}
if other.color.is_some() {
self.color = other.color;
}
if other.underline_type.is_some() {
self.underline_type = other.underline_type;
}
if other.underline_color.is_some() {
self.underline_color = other.underline_color;
}
if other.strikeout_shape.is_some() {
self.strikeout_shape = other.strikeout_shape;
}
if other.strikeout_color.is_some() {
self.strikeout_color = other.strikeout_color;
}
if other.outline.is_some() {
self.outline = other.outline;
}
if other.shadow.is_some() {
self.shadow = other.shadow;
}
if other.emboss.is_some() {
self.emboss = other.emboss;
}
if other.engrave.is_some() {
self.engrave = other.engrave;
}
if other.vertical_position.is_some() {
self.vertical_position = other.vertical_position;
}
if other.shade_color.is_some() {
self.shade_color = other.shade_color;
}
if other.emphasis.is_some() {
self.emphasis = other.emphasis;
}
if other.ratio.is_some() {
self.ratio = other.ratio;
}
if other.spacing.is_some() {
self.spacing = other.spacing;
}
if other.rel_sz.is_some() {
self.rel_sz = other.rel_sz;
}
if other.offset.is_some() {
self.offset = other.offset;
}
if other.use_kerning.is_some() {
self.use_kerning = other.use_kerning;
}
if other.use_font_space.is_some() {
self.use_font_space = other.use_font_space;
}
if other.char_border_fill_id.is_some() {
self.char_border_fill_id = other.char_border_fill_id;
}
}
pub fn resolve(&self, style_name: &str) -> BlueprintResult<CharShape> {
Ok(CharShape {
font: self.font.clone().ok_or_else(|| BlueprintError::StyleResolution {
style_name: style_name.to_string(),
field: "font".to_string(),
})?,
size: self.size.ok_or_else(|| BlueprintError::StyleResolution {
style_name: style_name.to_string(),
field: "size".to_string(),
})?,
bold: self.bold.unwrap_or(false),
italic: self.italic.unwrap_or(false),
color: self.color.unwrap_or(Color::BLACK),
underline_type: self.underline_type.unwrap_or(UnderlineType::None),
underline_color: self.underline_color,
strikeout_shape: self.strikeout_shape.unwrap_or(StrikeoutShape::None),
strikeout_color: self.strikeout_color,
outline: self.outline.unwrap_or(OutlineType::None),
shadow: self.shadow.unwrap_or(ShadowType::None),
emboss: self.emboss.unwrap_or(EmbossType::None),
engrave: self.engrave.unwrap_or(EngraveType::None),
vertical_position: self.vertical_position.unwrap_or(VerticalPosition::Normal),
shade_color: self.shade_color,
emphasis: self.emphasis.unwrap_or(EmphasisType::None),
ratio: self.ratio.unwrap_or(100),
spacing: self.spacing.unwrap_or(0),
rel_sz: self.rel_sz.unwrap_or(100),
offset: self.offset.unwrap_or(0),
use_kerning: self.use_kerning.unwrap_or(false),
use_font_space: self.use_font_space.unwrap_or(false),
char_border_fill_id: self.char_border_fill_id,
})
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct PartialParaShape {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<Alignment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<LineSpacing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spacing: Option<Spacing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub indent: Option<Indent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub break_type: Option<BreakType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_with_next: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keep_lines_together: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub widow_orphan: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_fill_id: Option<BorderFillIndex>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_def_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list: Option<PartialParagraphListRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heading_type: Option<HeadingType>,
}
impl Spacing {
pub fn merge(&mut self, other: &Spacing) {
if other.before.is_some() {
self.before = other.before;
}
if other.after.is_some() {
self.after = other.after;
}
}
}
impl Indent {
pub fn merge(&mut self, other: &Indent) {
if other.left.is_some() {
self.left = other.left;
}
if other.right.is_some() {
self.right = other.right;
}
if other.first_line.is_some() {
self.first_line = other.first_line;
}
}
}
impl LineSpacing {
pub fn merge(&mut self, other: &LineSpacing) {
if other.spacing_type.is_some() {
self.spacing_type = other.spacing_type;
}
if other.value.is_some() {
self.value = other.value;
}
}
}
impl PartialParaShape {
pub fn merge(&mut self, other: &PartialParaShape) {
if other.alignment.is_some() {
self.alignment = other.alignment;
}
match (&mut self.line_spacing, &other.line_spacing) {
(Some(base), Some(child)) => base.merge(child),
(None, Some(child)) => self.line_spacing = Some(*child),
_ => {}
}
match (&mut self.spacing, &other.spacing) {
(Some(base), Some(child)) => base.merge(child),
(None, Some(child)) => self.spacing = Some(*child),
_ => {}
}
match (&mut self.indent, &other.indent) {
(Some(base), Some(child)) => base.merge(child),
(None, Some(child)) => self.indent = Some(*child),
_ => {}
}
if other.break_type.is_some() {
self.break_type = other.break_type;
}
if other.keep_with_next.is_some() {
self.keep_with_next = other.keep_with_next;
}
if other.keep_lines_together.is_some() {
self.keep_lines_together = other.keep_lines_together;
}
if other.widow_orphan.is_some() {
self.widow_orphan = other.widow_orphan;
}
if other.border_fill_id.is_some() {
self.border_fill_id = other.border_fill_id;
}
if other.tab_def_id.is_some() {
self.tab_def_id = other.tab_def_id;
}
if other.list.is_some() {
self.list = other.list;
}
if other.heading_type.is_some() {
self.heading_type = other.heading_type;
}
}
pub fn resolve(
&self,
style_name: &str,
numberings: &[NumberingDef],
bullets: &[BulletDef],
) -> BlueprintResult<ParaShape> {
Ok(ParaShape {
alignment: self.alignment.unwrap_or(Alignment::Left),
line_spacing_type: self
.line_spacing
.and_then(|ls| ls.spacing_type)
.unwrap_or(LineSpacingType::Percentage),
line_spacing_value: self.line_spacing.and_then(|ls| ls.value).unwrap_or(160.0),
space_before: self.spacing.and_then(|s| s.before).unwrap_or(HwpUnit::ZERO),
space_after: self.spacing.and_then(|s| s.after).unwrap_or(HwpUnit::ZERO),
indent_left: self.indent.and_then(|i| i.left).unwrap_or(HwpUnit::ZERO),
indent_right: self.indent.and_then(|i| i.right).unwrap_or(HwpUnit::ZERO),
indent_first_line: self.indent.and_then(|i| i.first_line).unwrap_or(HwpUnit::ZERO),
break_type: self.break_type.unwrap_or(BreakType::None),
keep_with_next: self.keep_with_next.unwrap_or(false),
keep_lines_together: self.keep_lines_together.unwrap_or(false),
widow_orphan: self.widow_orphan.unwrap_or(true), border_fill_id: self.border_fill_id,
tab_def_id: self.tab_def_id.unwrap_or(0),
list: self.resolve_list(style_name, numberings, bullets)?,
})
}
fn resolve_list(
&self,
style_name: &str,
numberings: &[NumberingDef],
bullets: &[BulletDef],
) -> BlueprintResult<Option<ParagraphListRef>> {
if let Some(list) = self.list {
if matches!(
self.heading_type,
Some(HeadingType::Outline | HeadingType::Number | HeadingType::Bullet)
) {
return Err(BlueprintError::ConflictingListSpecification {
style_name: style_name.to_string(),
});
}
return list.resolve(style_name, numberings, bullets).map(Some);
}
match self.heading_type.unwrap_or(HeadingType::None) {
HeadingType::None => Ok(None),
HeadingType::Outline => Ok(Some(ParagraphListRef::Outline { level: 0 })),
heading_type => Err(BlueprintError::UnsupportedLegacyHeadingType {
style_name: style_name.to_string(),
heading_type,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct PartialStyle {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_shape: Option<PartialCharShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub para_shape: Option<PartialParaShape>,
}
impl PartialStyle {
pub fn merge(&mut self, other: &PartialStyle) {
match (&mut self.char_shape, &other.char_shape) {
(Some(base), Some(child)) => base.merge(child),
(None, Some(child)) => self.char_shape = Some(child.clone()),
_ => {}
}
match (&mut self.para_shape, &other.para_shape) {
(Some(base), Some(child)) => base.merge(child),
(None, Some(child)) => self.para_shape = Some(child.clone()),
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CharShape {
pub font: String,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub size: HwpUnit,
pub bold: bool,
pub italic: bool,
#[serde(serialize_with = "ser_color", deserialize_with = "de_color")]
pub color: Color,
pub underline_type: UnderlineType,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub underline_color: Option<Color>,
pub strikeout_shape: StrikeoutShape,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub strikeout_color: Option<Color>,
pub outline: OutlineType,
pub shadow: ShadowType,
pub emboss: EmbossType,
pub engrave: EngraveType,
pub vertical_position: VerticalPosition,
#[serde(
default,
serialize_with = "ser_color_opt",
deserialize_with = "de_color_opt",
skip_serializing_if = "Option::is_none"
)]
pub shade_color: Option<Color>,
pub emphasis: EmphasisType,
pub ratio: i32,
pub spacing: i32,
pub rel_sz: i32,
pub offset: i32,
pub use_kerning: bool,
pub use_font_space: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_border_fill_id: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ParaShape {
pub alignment: Alignment,
pub line_spacing_type: LineSpacingType,
pub line_spacing_value: f64,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub space_before: HwpUnit,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub space_after: HwpUnit,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub indent_left: HwpUnit,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub indent_right: HwpUnit,
#[serde(serialize_with = "ser_dim", deserialize_with = "de_dim")]
pub indent_first_line: HwpUnit,
pub break_type: BreakType,
pub keep_with_next: bool,
pub keep_lines_together: bool,
pub widow_orphan: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_fill_id: Option<BorderFillIndex>,
#[serde(default)]
pub tab_def_id: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list: Option<ParagraphListRef>,
}
#[cfg(test)]
mod tests {
use super::*;
use hwpforge_core::ParaHead;
use hwpforge_foundation::{BulletIndex, NumberFormatType, NumberingIndex};
use pretty_assertions::assert_eq;
fn test_numbering_def(id: u32) -> NumberingDef {
NumberingDef {
id,
start: 1,
levels: vec![ParaHead {
start: 1,
level: 1,
num_format: NumberFormatType::Digit,
text: "^1.".into(),
checkable: false,
}],
}
}
fn test_bullet_def(id: u32) -> BulletDef {
BulletDef {
id,
bullet_char: "•".into(),
checked_char: None,
use_image: false,
para_head: ParaHead {
start: 0,
level: 1,
num_format: NumberFormatType::Digit,
text: String::new(),
checkable: false,
},
}
}
#[test]
fn partial_char_shape_default_is_all_none() {
let p = PartialCharShape::default();
assert!(p.font.is_none());
assert!(p.size.is_none());
assert!(p.bold.is_none());
assert!(p.italic.is_none());
assert!(p.color.is_none());
assert!(p.underline_type.is_none());
assert!(p.strikeout_shape.is_none());
assert!(p.vertical_position.is_none());
}
#[test]
fn partial_char_shape_merge_overrides() {
let mut base = PartialCharShape {
font: Some("Arial".into()),
size: Some(HwpUnit::from_pt(10.0).unwrap()),
bold: Some(false),
..Default::default()
};
let child = PartialCharShape {
size: Some(HwpUnit::from_pt(16.0).unwrap()),
bold: Some(true),
..Default::default()
};
base.merge(&child);
assert_eq!(base.font, Some("Arial".into()));
assert_eq!(base.size, Some(HwpUnit::from_pt(16.0).unwrap()));
assert_eq!(base.bold, Some(true));
}
#[test]
fn partial_char_shape_merge_none_does_not_override() {
let mut base = PartialCharShape { font: Some("Batang".into()), ..Default::default() };
let child = PartialCharShape::default();
base.merge(&child);
assert_eq!(base.font, Some("Batang".into()));
}
#[test]
fn partial_char_shape_resolve_success() {
let partial = PartialCharShape {
font: Some("한컴바탕".into()),
size: Some(HwpUnit::from_pt(10.0).unwrap()),
..Default::default()
};
let resolved = partial.resolve("body").unwrap();
assert_eq!(resolved.font, "한컴바탕");
assert_eq!(resolved.size, HwpUnit::from_pt(10.0).unwrap());
assert!(!resolved.bold);
assert_eq!(resolved.color, Color::BLACK);
}
#[test]
fn partial_char_shape_resolve_missing_font() {
let partial =
PartialCharShape { size: Some(HwpUnit::from_pt(10.0).unwrap()), ..Default::default() };
let err = partial.resolve("heading1").unwrap_err();
assert!(err.to_string().contains("font"));
assert!(err.to_string().contains("heading1"));
}
#[test]
fn partial_char_shape_resolve_missing_size() {
let partial = PartialCharShape { font: Some("Arial".into()), ..Default::default() };
let err = partial.resolve("body").unwrap_err();
assert!(err.to_string().contains("size"));
}
#[test]
fn partial_para_shape_default_is_all_none() {
let p = PartialParaShape::default();
assert!(p.alignment.is_none());
assert!(p.line_spacing.is_none());
assert!(p.spacing.is_none());
assert!(p.indent.is_none());
}
#[test]
fn partial_para_shape_merge_overrides() {
let mut base = PartialParaShape {
alignment: Some(Alignment::Left),
line_spacing: Some(LineSpacing {
spacing_type: Some(LineSpacingType::Percentage),
value: Some(160.0),
}),
..Default::default()
};
let child = PartialParaShape {
line_spacing: Some(LineSpacing { spacing_type: None, value: Some(170.0) }),
..Default::default()
};
base.merge(&child);
assert_eq!(base.alignment, Some(Alignment::Left));
let ls = base.line_spacing.unwrap();
assert_eq!(ls.value, Some(170.0)); assert_eq!(ls.spacing_type, Some(LineSpacingType::Percentage)); }
#[test]
fn partial_para_shape_deep_merge_spacing() {
let mut base = PartialParaShape {
spacing: Some(Spacing { before: Some(HwpUnit::from_pt(6.0).unwrap()), after: None }),
..Default::default()
};
let child = PartialParaShape {
spacing: Some(Spacing { before: None, after: Some(HwpUnit::from_pt(12.0).unwrap()) }),
..Default::default()
};
base.merge(&child);
let sp = base.spacing.unwrap();
assert_eq!(sp.before, Some(HwpUnit::from_pt(6.0).unwrap())); assert_eq!(sp.after, Some(HwpUnit::from_pt(12.0).unwrap())); }
#[test]
fn partial_para_shape_deep_merge_indent() {
let mut base = PartialParaShape {
indent: Some(Indent {
left: Some(HwpUnit::from_pt(10.0).unwrap()),
right: None,
first_line: Some(HwpUnit::from_pt(5.0).unwrap()),
}),
..Default::default()
};
let child = PartialParaShape {
indent: Some(Indent {
left: None,
right: Some(HwpUnit::from_pt(8.0).unwrap()),
first_line: None,
}),
..Default::default()
};
base.merge(&child);
let indent = base.indent.unwrap();
assert_eq!(indent.left, Some(HwpUnit::from_pt(10.0).unwrap())); assert_eq!(indent.right, Some(HwpUnit::from_pt(8.0).unwrap())); assert_eq!(indent.first_line, Some(HwpUnit::from_pt(5.0).unwrap())); }
#[test]
fn partial_para_shape_resolve_defaults() {
let partial = PartialParaShape::default();
let resolved = partial.resolve("body", &[], &[]).unwrap();
assert_eq!(resolved.alignment, Alignment::Left);
assert_eq!(resolved.line_spacing_type, LineSpacingType::Percentage);
assert_eq!(resolved.line_spacing_value, 160.0);
assert_eq!(resolved.space_before, HwpUnit::ZERO);
assert_eq!(resolved.indent_left, HwpUnit::ZERO);
}
#[test]
fn partial_para_shape_resolve_list_number_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Number { numbering_id: 42, level: 2 }),
..Default::default()
};
let resolved = partial.resolve("body", &[test_numbering_def(42)], &[]).unwrap();
assert_eq!(
resolved.list,
Some(ParagraphListRef::Number { numbering_id: NumberingIndex::new(0), level: 2 })
);
}
#[test]
fn partial_para_shape_resolve_list_bullet_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Bullet { bullet_id: 7, level: 0 }),
..Default::default()
};
let resolved = partial.resolve("body", &[], &[test_bullet_def(7)]).unwrap();
assert_eq!(
resolved.list,
Some(ParagraphListRef::Bullet { bullet_id: BulletIndex::new(0), level: 0 })
);
}
#[test]
fn partial_para_shape_resolve_rejects_checkable_bullet_as_plain_bullet_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Bullet { bullet_id: 7, level: 0 }),
..Default::default()
};
let mut bullet = test_bullet_def(7);
bullet.checked_char = Some("☑".into());
bullet.para_head.checkable = true;
let err = partial.resolve("body", &[], &[bullet]).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidPlainBulletDefinition { bullet_id: 7, .. }));
}
#[test]
fn partial_para_shape_resolve_list_check_bullet_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::CheckBullet {
bullet_id: 7,
level: 1,
checked: true,
}),
..Default::default()
};
let mut bullet = test_bullet_def(7);
bullet.checked_char = Some("☑".into());
bullet.para_head.checkable = true;
let resolved = partial.resolve("body", &[], &[bullet]).unwrap();
assert_eq!(
resolved.list,
Some(ParagraphListRef::CheckBullet {
bullet_id: BulletIndex::new(0),
level: 1,
checked: true,
})
);
}
#[test]
fn partial_para_shape_resolve_rejects_non_checkable_check_bullet_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::CheckBullet {
bullet_id: 7,
level: 0,
checked: false,
}),
..Default::default()
};
let err = partial.resolve("body", &[], &[test_bullet_def(7)]).unwrap_err();
assert!(matches!(
err,
BlueprintError::InvalidCheckableBulletDefinition { bullet_id: 7, .. }
));
}
#[test]
fn partial_para_shape_resolve_legacy_outline_heading_type_migrates_to_list() {
let partial =
PartialParaShape { heading_type: Some(HeadingType::Outline), ..Default::default() };
let resolved = partial.resolve("body", &[], &[]).unwrap();
assert_eq!(resolved.list, Some(ParagraphListRef::Outline { level: 0 }));
}
#[test]
fn partial_para_shape_resolve_rejects_conflicting_legacy_heading_and_list() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Outline { level: 1 }),
heading_type: Some(HeadingType::Outline),
..Default::default()
};
let err = partial.resolve("body", &[], &[]).unwrap_err();
assert!(matches!(err, BlueprintError::ConflictingListSpecification { .. }));
}
#[test]
fn partial_para_shape_resolve_rejects_unsupported_legacy_number_heading_type() {
let partial =
PartialParaShape { heading_type: Some(HeadingType::Number), ..Default::default() };
let err = partial.resolve("body", &[], &[]).unwrap_err();
assert!(matches!(err, BlueprintError::UnsupportedLegacyHeadingType { .. }));
}
#[test]
fn partial_para_shape_resolve_rejects_invalid_list_reference() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Number { numbering_id: 999, level: 0 }),
..Default::default()
};
let err = partial.resolve("body", &[test_numbering_def(42)], &[]).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidListReference { .. }));
}
#[test]
fn partial_para_shape_resolve_rejects_invalid_list_level() {
let partial = PartialParaShape {
list: Some(PartialParagraphListRef::Outline { level: ParagraphListRef::MAX_LEVEL + 1 }),
..Default::default()
};
let err = partial.resolve("body", &[], &[]).unwrap_err();
assert!(matches!(err, BlueprintError::InvalidListLevel { .. }));
}
#[test]
fn partial_style_merge_both_present() {
let mut base = PartialStyle {
char_shape: Some(PartialCharShape {
font: Some("Arial".into()),
size: Some(HwpUnit::from_pt(10.0).unwrap()),
..Default::default()
}),
para_shape: Some(PartialParaShape {
alignment: Some(Alignment::Left),
..Default::default()
}),
};
let child = PartialStyle {
char_shape: Some(PartialCharShape {
size: Some(HwpUnit::from_pt(16.0).unwrap()),
bold: Some(true),
..Default::default()
}),
para_shape: None,
};
base.merge(&child);
let cs = base.char_shape.unwrap();
assert_eq!(cs.font, Some("Arial".into()));
assert_eq!(cs.size, Some(HwpUnit::from_pt(16.0).unwrap()));
assert_eq!(cs.bold, Some(true));
}
#[test]
fn partial_style_merge_none_base() {
let mut base = PartialStyle::default();
let child = PartialStyle {
char_shape: Some(PartialCharShape { font: Some("Dotum".into()), ..Default::default() }),
para_shape: None,
};
base.merge(&child);
assert_eq!(base.char_shape.unwrap().font, Some("Dotum".into()));
}
#[test]
fn char_shape_serde_roundtrip() {
let original = CharShape {
font: "한컴바탕".into(),
size: HwpUnit::from_pt(16.0).unwrap(),
bold: true,
italic: false,
color: Color::from_rgb(0x00, 0x33, 0x66),
underline_type: UnderlineType::None,
underline_color: None,
strikeout_shape: StrikeoutShape::None,
strikeout_color: None,
outline: OutlineType::None,
shadow: ShadowType::None,
emboss: EmbossType::None,
engrave: EngraveType::None,
vertical_position: VerticalPosition::Normal,
shade_color: None,
emphasis: EmphasisType::None,
ratio: 100,
spacing: 0,
rel_sz: 100,
offset: 0,
use_kerning: false,
use_font_space: false,
char_border_fill_id: None,
};
let yaml = serde_yaml::to_string(&original).unwrap();
let back: CharShape = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(original, back);
}
#[test]
fn char_shape_yaml_contains_human_readable() {
let cs = CharShape {
font: "Arial".into(),
size: HwpUnit::from_pt(12.0).unwrap(),
bold: false,
italic: true,
color: Color::RED,
underline_type: UnderlineType::None,
underline_color: None,
strikeout_shape: StrikeoutShape::None,
strikeout_color: None,
outline: OutlineType::None,
shadow: ShadowType::None,
emboss: EmbossType::None,
engrave: EngraveType::None,
vertical_position: VerticalPosition::Normal,
shade_color: None,
emphasis: EmphasisType::None,
ratio: 100,
spacing: 0,
rel_sz: 100,
offset: 0,
use_kerning: false,
use_font_space: false,
char_border_fill_id: None,
};
let yaml = serde_yaml::to_string(&cs).unwrap();
assert!(yaml.contains("12pt"), "Expected '12pt' in: {yaml}");
assert!(yaml.contains("#FF0000"), "Expected '#FF0000' in: {yaml}");
}
#[test]
fn para_shape_serde_roundtrip() {
let original = ParaShape {
alignment: Alignment::Justify,
line_spacing_type: LineSpacingType::Percentage,
line_spacing_value: 170.0,
space_before: HwpUnit::from_pt(6.0).unwrap(),
space_after: HwpUnit::from_pt(6.0).unwrap(),
indent_left: HwpUnit::ZERO,
indent_right: HwpUnit::ZERO,
indent_first_line: HwpUnit::ZERO,
break_type: BreakType::None,
keep_with_next: false,
keep_lines_together: false,
widow_orphan: true,
border_fill_id: None,
tab_def_id: 0,
list: None,
};
let yaml = serde_yaml::to_string(&original).unwrap();
let back: ParaShape = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(original, back);
}
#[test]
fn para_shape_yaml_defaults_missing_tab_def_id_to_zero() {
let yaml = r#"
alignment: Justify
line_spacing_type: Percentage
line_spacing_value: 160.0
space_before: 0pt
space_after: 0pt
indent_left: 0pt
indent_right: 0pt
indent_first_line: 0pt
break_type: None
keep_with_next: false
keep_lines_together: false
widow_orphan: true
"#;
let parsed: ParaShape = serde_yaml::from_str(yaml).unwrap();
assert_eq!(parsed.tab_def_id, 0);
assert_eq!(parsed.list, None);
}
#[test]
fn partial_char_shape_from_yaml() {
let yaml = "font: 한컴바탕\nsize: 16pt\nbold: true\ncolor: '#003366'\n";
let partial: PartialCharShape = serde_yaml::from_str(yaml).unwrap();
assert_eq!(partial.font, Some("한컴바탕".into()));
assert_eq!(partial.size, Some(HwpUnit::from_pt(16.0).unwrap()));
assert_eq!(partial.bold, Some(true));
assert_eq!(partial.color, Some(Color::from_rgb(0x00, 0x33, 0x66)));
assert!(partial.italic.is_none());
}
#[test]
fn partial_para_shape_from_yaml() {
let yaml = "alignment: Justify\nline_spacing:\n value: '170%'\nspacing:\n before: '6pt'\n after: '6pt'\n";
let partial: PartialParaShape = serde_yaml::from_str(yaml).unwrap();
assert_eq!(partial.alignment, Some(Alignment::Justify));
assert_eq!(partial.line_spacing.unwrap().value, Some(170.0));
assert_eq!(partial.spacing.unwrap().before, Some(HwpUnit::from_pt(6.0).unwrap()));
}
#[test]
fn partial_style_from_yaml() {
let yaml = "char_shape:\n font: Arial\n size: '10pt'\npara_shape:\n alignment: Left\n";
let style: PartialStyle = serde_yaml::from_str(yaml).unwrap();
assert_eq!(style.char_shape.as_ref().unwrap().font, Some("Arial".into()));
assert_eq!(style.para_shape.as_ref().unwrap().alignment, Some(Alignment::Left));
}
#[test]
fn spacing_from_yaml() {
let yaml = "before: '6pt'\nafter: '12pt'\n";
let spacing: Spacing = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spacing.before, Some(HwpUnit::from_pt(6.0).unwrap()));
assert_eq!(spacing.after, Some(HwpUnit::from_pt(12.0).unwrap()));
}
#[test]
fn indent_from_yaml() {
let yaml = "left: '20mm'\nfirst_line: '10pt'\n";
let indent: Indent = serde_yaml::from_str(yaml).unwrap();
assert_eq!(indent.left, Some(HwpUnit::from_mm(20.0).unwrap()));
assert_eq!(indent.first_line, Some(HwpUnit::from_pt(10.0).unwrap()));
assert!(indent.right.is_none());
}
#[test]
fn line_spacing_from_yaml() {
let yaml = "spacing_type: Percentage\nvalue: '160%'\n";
let ls: LineSpacing = serde_yaml::from_str(yaml).unwrap();
assert_eq!(ls.spacing_type, Some(LineSpacingType::Percentage));
assert_eq!(ls.value, Some(160.0));
}
}