use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnderlineStyle {
Single,
Double,
Dotted,
Dash,
Wavy,
Words,
None,
Custom(String),
}
impl UnderlineStyle {
pub(crate) fn from_xml(value: &str) -> Self {
match value {
"single" => Self::Single,
"double" => Self::Double,
"dotted" => Self::Dotted,
"dash" => Self::Dash,
"wave" => Self::Wavy,
"words" => Self::Words,
"none" => Self::None,
other => Self::Custom(other.to_string()),
}
}
pub(crate) fn as_xml_value(&self) -> &str {
match self {
Self::Single => "single",
Self::Double => "double",
Self::Dotted => "dotted",
Self::Dash => "dash",
Self::Wavy => "wave",
Self::Words => "words",
Self::None => "none",
Self::Custom(value) => value.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerticalAlign {
Superscript,
Subscript,
Baseline,
}
impl VerticalAlign {
pub(crate) fn from_xml(value: &str) -> Self {
match value {
"superscript" => Self::Superscript,
"subscript" => Self::Subscript,
_ => Self::Baseline,
}
}
pub(crate) fn as_xml_value(&self) -> &str {
match self {
Self::Superscript => "superscript",
Self::Subscript => "subscript",
Self::Baseline => "baseline",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunProperties {
pub bold: bool,
pub italic: bool,
pub underline: Option<UnderlineStyle>,
pub strikethrough: bool,
pub small_caps: bool,
pub shadow: bool,
pub color: Option<String>,
pub font_size: Option<u16>,
pub font_family: Option<String>,
pub vertical_align: Option<VerticalAlign>,
}
impl RunProperties {
pub(crate) fn has_serialized_content(&self) -> bool {
self.bold
|| self.italic
|| self.underline.is_some()
|| self.strikethrough
|| self.small_caps
|| self.shadow
|| self.color.is_some()
|| self.font_size.is_some()
|| self.font_family.is_some()
|| self.vertical_align.is_some()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Run {
text: String,
style_id: Option<String>,
properties: RunProperties,
}
impl Run {
pub fn new() -> Self {
Self::default()
}
pub fn from_text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
style_id: None,
properties: RunProperties::default(),
}
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
pub fn text(&self) -> &str {
self.text.as_str()
}
pub fn style_id(&self) -> Option<&str> {
self.style_id.as_deref()
}
pub fn with_style(mut self, style_id: impl Into<String>) -> Self {
self.style_id = Some(style_id.into());
self
}
pub fn set_style(&mut self, style_id: impl Into<String>) -> &mut Self {
self.style_id = Some(style_id.into());
self
}
pub fn set_text(&mut self, text: impl Into<String>) -> &mut Self {
self.text = text.into();
self
}
pub fn properties(&self) -> &RunProperties {
&self.properties
}
pub fn properties_mut(&mut self) -> &mut RunProperties {
&mut self.properties
}
pub fn bold(mut self) -> Self {
self.properties.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.properties.italic = true;
self
}
pub fn underline(mut self, style: UnderlineStyle) -> Self {
self.properties.underline = Some(style);
self
}
pub fn strikethrough(mut self) -> Self {
self.properties.strikethrough = true;
self
}
pub fn small_caps(mut self) -> Self {
self.properties.small_caps = true;
self
}
pub fn shadow(mut self) -> Self {
self.properties.shadow = true;
self
}
pub fn color(mut self, value: impl Into<String>) -> Self {
self.properties.color = Some(value.into());
self
}
pub fn font(mut self, value: impl Into<String>) -> Self {
self.properties.font_family = Some(value.into());
self
}
pub fn size_points(mut self, points: u16) -> Self {
self.properties.font_size = Some(points.saturating_mul(2));
self
}
pub fn size_half_points(mut self, half_points: u16) -> Self {
self.properties.font_size = Some(half_points);
self
}
pub fn superscript(mut self) -> Self {
self.properties.vertical_align = Some(VerticalAlign::Superscript);
self
}
pub fn subscript(mut self) -> Self {
self.properties.vertical_align = Some(VerticalAlign::Subscript);
self
}
pub(crate) fn from_parts(
text: String,
style_id: Option<String>,
properties: RunProperties,
) -> Self {
Self {
text,
style_id,
properties,
}
}
pub(crate) fn needs_space_preserve(&self) -> bool {
let starts_with_ws = self.text.chars().next().is_some_and(char::is_whitespace);
let ends_with_ws = self.text.chars().last().is_some_and(char::is_whitespace);
starts_with_ws || ends_with_ws
}
}
#[cfg(test)]
mod tests {
use super::{Run, RunProperties, UnderlineStyle, VerticalAlign};
#[test]
fn underline_style_round_trips_known_values() {
let cases = [
("single", UnderlineStyle::Single),
("double", UnderlineStyle::Double),
("dotted", UnderlineStyle::Dotted),
("dash", UnderlineStyle::Dash),
("wave", UnderlineStyle::Wavy),
("words", UnderlineStyle::Words),
("none", UnderlineStyle::None),
];
for (xml, expected) in cases {
let parsed = UnderlineStyle::from_xml(xml);
assert_eq!(parsed, expected);
assert_eq!(parsed.as_xml_value(), xml);
}
}
#[test]
fn underline_style_preserves_custom_xml_value() {
let parsed = UnderlineStyle::from_xml("thick");
assert_eq!(parsed, UnderlineStyle::Custom("thick".to_string()));
assert_eq!(parsed.as_xml_value(), "thick");
}
#[test]
fn vertical_align_round_trips_known_values() {
let superscript = VerticalAlign::from_xml("superscript");
let subscript = VerticalAlign::from_xml("subscript");
let baseline = VerticalAlign::from_xml("anything-else");
assert_eq!(superscript, VerticalAlign::Superscript);
assert_eq!(subscript, VerticalAlign::Subscript);
assert_eq!(baseline, VerticalAlign::Baseline);
assert_eq!(superscript.as_xml_value(), "superscript");
assert_eq!(subscript.as_xml_value(), "subscript");
assert_eq!(baseline.as_xml_value(), "baseline");
}
#[test]
fn run_properties_serialization_flag_tracks_all_fields() {
let mut properties = RunProperties::default();
assert!(!properties.has_serialized_content());
properties.bold = true;
assert!(properties.has_serialized_content());
properties.bold = false;
properties.italic = true;
assert!(properties.has_serialized_content());
properties.italic = false;
properties.underline = Some(UnderlineStyle::Single);
assert!(properties.has_serialized_content());
properties.underline = None;
properties.strikethrough = true;
assert!(properties.has_serialized_content());
properties.strikethrough = false;
properties.small_caps = true;
assert!(properties.has_serialized_content());
properties.small_caps = false;
properties.shadow = true;
assert!(properties.has_serialized_content());
properties.shadow = false;
properties.color = Some("FF0000".to_string());
assert!(properties.has_serialized_content());
properties.color = None;
properties.font_size = Some(24);
assert!(properties.has_serialized_content());
properties.font_size = None;
properties.font_family = Some("Arial".to_string());
assert!(properties.has_serialized_content());
properties.font_family = None;
properties.vertical_align = Some(VerticalAlign::Superscript);
assert!(properties.has_serialized_content());
}
#[test]
fn run_builder_methods_apply_expected_properties() {
let run = Run::from_text("x")
.bold()
.italic()
.underline(UnderlineStyle::Double)
.strikethrough()
.small_caps()
.shadow()
.color("AABBCC")
.font("Inter")
.size_points(12)
.superscript();
assert_eq!(run.text(), "x");
assert!(run.properties().bold);
assert!(run.properties().italic);
assert_eq!(run.properties().underline, Some(UnderlineStyle::Double));
assert!(run.properties().strikethrough);
assert!(run.properties().small_caps);
assert!(run.properties().shadow);
assert_eq!(run.properties().color.as_deref(), Some("AABBCC"));
assert_eq!(run.properties().font_family.as_deref(), Some("Inter"));
assert_eq!(run.properties().font_size, Some(24));
assert_eq!(
run.properties().vertical_align,
Some(VerticalAlign::Superscript)
);
}
#[test]
fn size_points_saturates_and_half_points_is_exact() {
let saturated = Run::new().size_points(u16::MAX);
let exact = Run::new().size_half_points(65530);
assert_eq!(saturated.properties().font_size, Some(u16::MAX));
assert_eq!(exact.properties().font_size, Some(65530));
}
#[test]
fn set_text_and_properties_mut_work_in_place() {
let mut run = Run::new().with_text("first");
run.set_text("second");
run.properties_mut().bold = true;
run.properties_mut().font_family = Some("Arial".to_string());
assert_eq!(run.text(), "second");
assert!(run.properties().bold);
assert_eq!(run.properties().font_family.as_deref(), Some("Arial"));
}
#[test]
fn needs_space_preserve_detects_boundary_whitespace() {
assert!(Run::from_text(" leading").needs_space_preserve());
assert!(Run::from_text("trailing ").needs_space_preserve());
assert!(Run::from_text("\nline").needs_space_preserve());
assert!(!Run::from_text("in ter nal").needs_space_preserve());
assert!(!Run::from_text("plain").needs_space_preserve());
}
#[test]
fn subscript_overwrites_previous_vertical_alignment() {
let run = Run::from_text("chem").superscript().subscript();
assert_eq!(
run.properties().vertical_align,
Some(VerticalAlign::Subscript)
);
}
#[test]
fn from_parts_preserves_text_and_properties() {
let properties = RunProperties {
bold: true,
italic: true,
underline: Some(UnderlineStyle::Single),
strikethrough: false,
small_caps: true,
shadow: false,
color: Some("112233".to_string()),
font_size: Some(28),
font_family: Some("Calibri".to_string()),
vertical_align: Some(VerticalAlign::Baseline),
};
let run = Run::from_parts("abc".to_string(), None, properties.clone());
assert_eq!(run.text(), "abc");
assert_eq!(run.properties(), &properties);
}
}