use oxml_layout::{
Align, InlineItem, LayoutLine, LineBreakParams, LineSpacing, TabAlign, TabLeader, TabStop,
TextSegment, Underline,
};
use rdocx_oxml::borders::CT_TabStop;
use rdocx_oxml::properties::CT_PPr;
use rdocx_oxml::shared::{ST_Jc, ST_TabJc, ST_TabLeader, ST_Underline};
use unicode_linebreak::linebreaks;
pub(crate) fn alignment(value: Option<ST_Jc>) -> Option<Align> {
value.map(|value| match value {
ST_Jc::Start | ST_Jc::Left => Align::Start,
ST_Jc::Center => Align::Center,
ST_Jc::End | ST_Jc::Right => Align::End,
ST_Jc::Both => Align::Justify,
ST_Jc::Distribute => Align::Distribute,
})
}
pub(crate) fn tab_stops(values: &[CT_TabStop]) -> Vec<TabStop> {
values
.iter()
.filter_map(|value| {
let align = match value.val {
ST_TabJc::Left => TabAlign::Left,
ST_TabJc::Center => TabAlign::Center,
ST_TabJc::Right => TabAlign::Right,
ST_TabJc::Decimal | ST_TabJc::Num => TabAlign::Decimal,
ST_TabJc::Bar => TabAlign::Bar,
ST_TabJc::Clear => return None,
};
let leader = value.leader.map(|leader| match leader {
ST_TabLeader::None => TabLeader::None,
ST_TabLeader::Dot => TabLeader::Dot,
ST_TabLeader::Hyphen => TabLeader::Hyphen,
ST_TabLeader::Underscore => TabLeader::Underscore,
ST_TabLeader::Heavy => TabLeader::Heavy,
ST_TabLeader::MiddleDot => TabLeader::MiddleDot,
});
Some(TabStop {
pos_pt: value.pos.to_pt(),
align,
leader,
})
})
.collect()
}
pub(crate) fn underline(value: Option<ST_Underline>) -> Option<Underline> {
value.and_then(|value| match value {
ST_Underline::None => None,
ST_Underline::Single => Some(Underline::Single),
ST_Underline::Words => Some(Underline::Words),
ST_Underline::Double => Some(Underline::Double),
ST_Underline::Thick => Some(Underline::Thick),
ST_Underline::Dotted => Some(Underline::Dotted),
ST_Underline::Dash => Some(Underline::Dash),
ST_Underline::DotDash => Some(Underline::DotDash),
ST_Underline::DotDotDash => Some(Underline::DotDotDash),
ST_Underline::Wave => Some(Underline::Wave),
})
}
pub(crate) fn line_spacing(properties: &CT_PPr) -> LineSpacing {
match (properties.line_spacing, properties.line_rule.as_deref()) {
(Some(spacing), Some("exact")) => LineSpacing::Exact(spacing.to_pt()),
(Some(spacing), Some("atLeast")) => LineSpacing::AtLeast(spacing.to_pt()),
(Some(spacing), _) => LineSpacing::Multiple(spacing.0 as f64 / 240.0),
(None, _) => LineSpacing::Single,
}
}
pub(crate) fn line_break_params(properties: &CT_PPr, available_width: f64) -> LineBreakParams {
LineBreakParams {
line_prefix_widths: Vec::new(),
line_suffix_widths: Vec::new(),
available_width,
ind_left: properties.ind_left.map_or(0.0, |value| value.to_pt()),
ind_right: properties.ind_right.map_or(0.0, |value| value.to_pt()),
ind_first_line: properties.ind_first_line.map_or(0.0, |value| value.to_pt()),
ind_hanging: properties.ind_hanging.map_or(0.0, |value| value.to_pt()),
tab_stops: properties
.tabs
.as_ref()
.map_or_else(Vec::new, |tabs| tab_stops(&tabs.tabs)),
line_spacing: line_spacing(properties),
jc: alignment(properties.jc),
wrap: true,
}
}
pub(crate) fn text_segments(segment: TextSegment) -> Vec<InlineItem> {
let mut ranges = Vec::new();
let mut start = 0;
for (end, _) in linebreaks(&segment.text) {
if end > start {
ranges.push((start, end));
start = end;
}
}
if ranges.is_empty() && !segment.text.is_empty() {
ranges.push((0, segment.text.len()));
}
ranges
.into_iter()
.map(|(start, end)| InlineItem::Text(slice_text_segment(&segment, start, end)))
.collect()
}
pub(crate) fn restore_word_line_heights(lines: &mut [LayoutLine], properties: &CT_PPr) {
for line in lines {
let natural = line.ascent + line.descent;
let natural = if natural < 1.0 { 12.0 } else { natural };
line.line_gap = 0.0;
line.height = match (properties.line_spacing, properties.line_rule.as_deref()) {
(Some(spacing), Some("exact")) => spacing.to_pt(),
(Some(spacing), Some("atLeast")) => natural.max(spacing.to_pt()),
(Some(spacing), _) => natural * spacing.0 as f64 / 240.0,
(None, _) => natural,
};
}
}
fn slice_text_segment(segment: &TextSegment, byte_start: usize, byte_end: usize) -> TextSegment {
if byte_start == 0 && byte_end == segment.text.len() {
return segment.clone();
}
let text = segment.text[byte_start..byte_end].to_string();
let total_chars = segment.text.chars().count();
let char_start = segment.text[..byte_start].chars().count();
let char_count = text.chars().count();
let (glyph_ids, advances) = if segment.glyph_ids.len() == total_chars {
let end = (char_start + char_count).min(segment.glyph_ids.len());
(
segment.glyph_ids[char_start..end].to_vec(),
segment.advances[char_start..end].to_vec(),
)
} else if segment.glyph_ids.is_empty() || segment.text.is_empty() {
(Vec::new(), Vec::new())
} else {
let byte_fraction = (byte_end - byte_start) as f64 / segment.text.len() as f64;
let glyph_count = (segment.glyph_ids.len() as f64 * byte_fraction).round() as usize;
let glyph_start = (segment.glyph_ids.len() as f64 * byte_start as f64
/ segment.text.len() as f64)
.round() as usize;
let glyph_end = (glyph_start + glyph_count).min(segment.glyph_ids.len());
(
segment.glyph_ids[glyph_start..glyph_end].to_vec(),
segment.advances[glyph_start..glyph_end].to_vec(),
)
};
let width = advances.iter().sum();
TextSegment {
text,
glyph_ids,
advances,
width,
..segment.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxml_layout::{Align, LineSpacing, TabAlign, TabLeader, Underline};
use rdocx_oxml::units::Twips;
#[test]
fn every_word_alignment_maps_to_the_shared_alignment() {
let cases = [
(ST_Jc::Start, Align::Start),
(ST_Jc::Left, Align::Start),
(ST_Jc::Center, Align::Center),
(ST_Jc::End, Align::End),
(ST_Jc::Right, Align::End),
(ST_Jc::Both, Align::Justify),
(ST_Jc::Distribute, Align::Distribute),
];
for (word, shared) in cases {
assert_eq!(alignment(Some(word)), Some(shared));
}
assert_eq!(alignment(None), None);
}
#[test]
fn every_word_tab_and_leader_maps_without_rounding_its_position() {
let cases = [
(ST_TabJc::Left, TabAlign::Left),
(ST_TabJc::Center, TabAlign::Center),
(ST_TabJc::Right, TabAlign::Right),
(ST_TabJc::Decimal, TabAlign::Decimal),
(ST_TabJc::Bar, TabAlign::Bar),
(ST_TabJc::Num, TabAlign::Decimal),
];
let leaders = [
(ST_TabLeader::None, TabLeader::None),
(ST_TabLeader::Dot, TabLeader::Dot),
(ST_TabLeader::Hyphen, TabLeader::Hyphen),
(ST_TabLeader::Underscore, TabLeader::Underscore),
(ST_TabLeader::Heavy, TabLeader::Heavy),
(ST_TabLeader::MiddleDot, TabLeader::MiddleDot),
];
for ((word_align, shared_align), (word_leader, shared_leader)) in
cases.into_iter().zip(leaders)
{
let converted = tab_stops(&[CT_TabStop {
val: word_align,
pos: Twips(1),
leader: Some(word_leader),
source_occurrence: None,
}]);
assert_eq!(converted.len(), 1);
assert_eq!(converted[0].align, shared_align);
assert_eq!(converted[0].leader, Some(shared_leader));
assert!((converted[0].pos_pt - 0.05).abs() < f64::EPSILON);
}
assert!(tab_stops(&[CT_TabStop::new(ST_TabJc::Clear, Twips(720))]).is_empty());
}
#[test]
fn every_rendered_word_underline_maps_to_the_shared_style() {
let cases = [
(ST_Underline::Single, Underline::Single),
(ST_Underline::Words, Underline::Words),
(ST_Underline::Double, Underline::Double),
(ST_Underline::Thick, Underline::Thick),
(ST_Underline::Dotted, Underline::Dotted),
(ST_Underline::Dash, Underline::Dash),
(ST_Underline::DotDash, Underline::DotDash),
(ST_Underline::DotDotDash, Underline::DotDotDash),
(ST_Underline::Wave, Underline::Wave),
];
for (word, shared) in cases {
assert_eq!(underline(Some(word)), Some(shared));
}
assert_eq!(underline(Some(ST_Underline::None)), None);
assert_eq!(underline(None), None);
}
#[test]
fn every_word_line_spacing_rule_maps_exactly() {
let cases = [
(Some(Twips(1)), Some("exact"), LineSpacing::Exact(0.05)),
(
Some(Twips(480)),
Some("atLeast"),
LineSpacing::AtLeast(24.0),
),
(Some(Twips(360)), Some("auto"), LineSpacing::Multiple(1.5)),
(Some(Twips(240)), None, LineSpacing::Multiple(1.0)),
(None, None, LineSpacing::Single),
];
for (spacing, rule, expected) in cases {
let properties = CT_PPr {
line_spacing: spacing,
line_rule: rule.map(str::to_owned),
..Default::default()
};
assert_eq!(line_spacing(&properties), expected);
}
}
#[test]
fn word_line_parameters_keep_wrap_enabled() {
let params = line_break_params(&CT_PPr::default(), 321.0);
assert_eq!(params.available_width, 321.0);
assert!(params.wrap);
}
#[test]
fn word_text_preserves_pre_cutover_glyph_slices_at_wrap_boundaries() {
use oxml_layout::{Color, FontId};
let segment = TextSegment {
text: "one two".to_string(),
font_id: FontId(1),
font_size: 12.0,
glyph_ids: (1..=7).collect(),
advances: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
width: 28.0,
ascent: 9.0,
descent: 3.0,
line_gap: 0.0,
color: Color::BLACK,
bold: false,
italic: false,
underline: None,
strike: false,
dstrike: false,
highlight: None,
baseline_offset: 0.0,
hyperlink_url: None,
field_kind: None,
note: None,
};
let items = text_segments(segment);
assert_eq!(items.len(), 2);
let InlineItem::Text(first) = &items[0] else {
panic!("expected a text segment");
};
assert_eq!(first.text, "one ");
assert_eq!(first.glyph_ids, vec![1, 2, 3, 4]);
assert_eq!(first.width, 10.0);
}
#[test]
fn word_auto_spacing_uses_glyph_height_not_point_size() {
let mut lines = vec![LayoutLine {
items: Vec::new(),
width: 0.0,
ascent: 10.0,
descent: 3.0,
line_gap: 2.0,
height: 12.0,
indent_left: 0.0,
available_width: 468.0,
is_last: true,
}];
let properties = CT_PPr {
line_spacing: Some(Twips(480)),
line_rule: Some("auto".to_string()),
..Default::default()
};
restore_word_line_heights(&mut lines, &properties);
assert_eq!(lines[0].height, 26.0);
assert_eq!(lines[0].line_gap, 0.0);
}
}