use std::collections::HashMap;
use std::io::{Read, Seek};
use crate::model::{
ConnectorShape, FieldCode, FloatingImage, HorizontalRule, InlineChart, Run, SmartArtDiagram,
TabAlignment, TextFill, TextGlow, TextOutline, TextShadow, Textbox, VertAlign,
};
use super::images::{
RunDrawingResult, parse_object_floating_image, parse_object_inline_image, parse_run_drawing,
};
use super::is_east_asian_char;
use super::styles::{
CharacterStyle, ParagraphStyle, StyleDefaults, ThemeFonts, parse_char_spacing, parse_font_size,
resolve_east_asia_font_from_node, resolve_font_from_node, resolve_font_from_node_opt,
};
use super::textbox::parse_textbox_from_vml;
use super::wordart::{parse_text_fill, parse_text_glow, parse_text_outline, parse_text_shadow};
use super::{
MC_NS_TOP, ParseContext, REL_NS, VML_NS, WML_NS, highlight_color, parse_hex_color, parse_pt,
parse_one_border, parse_run_shd, parse_text_color, wml, wml_attr, wml_bool,
};
fn is_dynamic_field(instr: &str) -> bool {
let keyword = instr.split_whitespace().next().unwrap_or("");
keyword.eq_ignore_ascii_case("PAGE")
|| keyword.eq_ignore_ascii_case("NUMPAGES")
|| keyword.eq_ignore_ascii_case("STYLEREF")
|| keyword.eq_ignore_ascii_case("PAGEREF")
}
struct FieldFrame {
seen_sep: bool,
visible: bool,
instr: String,
result: String,
}
fn parse_styleref_arg(instr: &str) -> Option<String> {
let trimmed = instr.trim();
let kw = trimmed.split_whitespace().next()?;
if !kw.eq_ignore_ascii_case("styleref") {
return None;
}
let rest = trimmed[kw.len()..].trim();
if let Some(quoted) = rest.strip_prefix('"') {
let end = quoted.find('"')?;
Some(quoted[..end].to_string())
} else {
Some(rest.split_whitespace().next()?.to_string())
}
}
fn mc_choice_or_fallback<'a>(node: roxmltree::Node<'a, 'a>) -> Option<roxmltree::Node<'a, 'a>> {
let mut fallback: Option<roxmltree::Node<'a, 'a>> = None;
for n in node.children() {
if n.tag_name().namespace() != Some(MC_NS_TOP) {
continue;
}
match n.tag_name().name() {
"Choice" => return Some(n),
"Fallback" if fallback.is_none() => fallback = Some(n),
_ => {}
}
}
fallback
}
pub(super) struct ParsedRuns {
pub(super) runs: Vec<Run>,
pub(super) has_page_break_before: bool,
pub(super) has_explicit_page_break_before: bool,
pub(super) has_page_break_after: bool,
pub(super) has_column_break: bool,
pub(super) has_clear_break: bool,
pub(super) floating_images: Vec<FloatingImage>,
pub(super) textboxes: Vec<Textbox>,
pub(super) connectors: Vec<ConnectorShape>,
pub(super) inline_chart: Option<InlineChart>,
pub(super) smartart: Vec<SmartArtDiagram>,
pub(super) horizontal_rule: Option<HorizontalRule>,
}
struct RunFormat {
font_size: f32,
font_name: String,
east_asia_font_name: Option<String>,
bold: bool,
italic: bool,
underline: bool,
double_underline: bool,
strikethrough: bool,
dstrike: bool,
char_spacing: f32,
text_scale: f32,
caps: bool,
small_caps: bool,
vanish: bool,
color: Option<[u8; 3]>,
vertical_align: VertAlign,
highlight: Option<[u8; 3]>,
shading: Option<[u8; 3]>,
border: Option<crate::model::ParagraphBorder>,
kern_threshold: Option<f32>,
char_style_id: Option<String>,
text_outline: Option<TextOutline>,
text_fill: Option<TextFill>,
text_shadow: Option<TextShadow>,
text_glow: Option<TextGlow>,
lang: Option<String>,
font_size_from_default: bool,
font_name_from_default: bool,
}
impl RunFormat {
fn text_run(&self, text: String, hyperlink_url: Option<String>) -> Run {
Run {
text,
font_size: self.font_size,
font_name: self.font_name.clone(),
east_asia_font_name: self.east_asia_font_name.clone(),
bold: self.bold,
italic: self.italic,
underline: self.underline,
double_underline: self.double_underline,
strikethrough: self.strikethrough,
dstrike: self.dstrike,
char_spacing: self.char_spacing,
text_scale: self.text_scale,
caps: self.caps,
small_caps: self.small_caps,
vanish: self.vanish,
color: self.color,
vertical_align: self.vertical_align,
highlight: self.highlight,
shading: self.shading,
border: self.border.clone(),
kern_threshold: self.kern_threshold,
char_style_id: self.char_style_id.clone(),
text_outline: self.text_outline.clone(),
text_fill: self.text_fill.clone(),
text_shadow: self.text_shadow.clone(),
text_glow: self.text_glow.clone(),
lang: self.lang.clone(),
font_size_from_default: self.font_size_from_default,
font_name_from_default: self.font_name_from_default,
hyperlink_url,
..Run::default()
}
}
fn minimal_run(&self) -> Run {
Run {
font_size: self.font_size,
font_name: self.font_name.clone(),
..Run::default()
}
}
fn tab_run(&self) -> Run {
Run {
is_tab: true,
font_size: self.font_size,
font_name: self.font_name.clone(),
underline: self.underline,
double_underline: self.double_underline,
color: self.color,
..Run::default()
}
}
fn ptab_run(&self, alignment: TabAlignment) -> Run {
Run {
is_tab: true,
ptab_alignment: Some(alignment),
font_size: self.font_size,
font_name: self.font_name.clone(),
color: self.color,
..Run::default()
}
}
fn styled_run(&self) -> Run {
Run {
font_size: self.font_size,
font_name: self.font_name.clone(),
bold: self.bold,
italic: self.italic,
color: self.color,
highlight: self.highlight,
shading: self.shading,
..Run::default()
}
}
fn superscript_run(&self) -> Run {
Run {
vertical_align: VertAlign::Superscript,
..self.styled_run()
}
}
}
struct ParagraphRunDefaults {
font_size: f32,
font_name: String,
font_size_is_doc_default: bool,
font_name_is_doc_default: bool,
bold: bool,
italic: bool,
caps: bool,
small_caps: bool,
vanish: bool,
underline: bool,
double_underline: bool,
strikethrough: bool,
dstrike: bool,
color: Option<[u8; 3]>,
char_spacing: f32,
kern_threshold: Option<f32>,
east_asia_font: Option<String>,
text_outline: Option<TextOutline>,
text_fill: Option<TextFill>,
text_shadow: Option<TextShadow>,
text_glow: Option<TextGlow>,
}
impl ParagraphRunDefaults {
fn from_style(para_style: Option<&ParagraphStyle>, defaults: &StyleDefaults) -> Self {
let para_font_size = para_style.and_then(|s| s.font_size);
let para_font_name = para_style.and_then(|s| s.font_name.as_deref());
Self {
font_size: para_font_size.unwrap_or(defaults.font_size),
font_name: para_font_name.unwrap_or(&defaults.font_name).to_string(),
font_size_is_doc_default: para_font_size.is_none(),
font_name_is_doc_default: para_font_name.is_none(),
bold: para_style
.and_then(|s| s.bold)
.unwrap_or(defaults.bold),
italic: para_style
.and_then(|s| s.italic)
.unwrap_or(defaults.italic),
caps: para_style
.and_then(|s| s.caps)
.unwrap_or(defaults.caps),
small_caps: para_style
.and_then(|s| s.small_caps)
.unwrap_or(defaults.small_caps),
vanish: para_style
.and_then(|s| s.vanish)
.unwrap_or(defaults.vanish),
underline: para_style
.and_then(|s| s.underline)
.unwrap_or(defaults.underline),
double_underline: para_style
.and_then(|s| s.double_underline)
.unwrap_or(defaults.double_underline),
strikethrough: para_style
.and_then(|s| s.strikethrough)
.unwrap_or(defaults.strikethrough),
dstrike: para_style
.and_then(|s| s.dstrike)
.unwrap_or(defaults.dstrike),
color: para_style.and_then(|s| s.color).or(defaults.color),
char_spacing: para_style
.and_then(|s| s.char_spacing)
.unwrap_or(defaults.char_spacing),
kern_threshold: para_style
.and_then(|s| s.kern_threshold)
.or(defaults.kern_threshold),
east_asia_font: para_style
.and_then(|s| s.east_asia_font.clone())
.or_else(|| defaults.east_asia_font.clone()),
text_outline: para_style.and_then(|s| s.text_outline.clone()),
text_fill: para_style.and_then(|s| s.text_fill.clone()),
text_shadow: para_style.and_then(|s| s.text_shadow.clone()),
text_glow: para_style.and_then(|s| s.text_glow.clone()),
}
}
fn resolve_run_format(
&self,
rpr: Option<roxmltree::Node>,
char_style: Option<&CharacterStyle>,
char_style_id_str: Option<&str>,
theme: &ThemeFonts,
) -> RunFormat {
let rfonts_node = rpr.and_then(|n| wml(n, "rFonts"));
let explicit_font_size = rpr.and_then(parse_font_size);
let char_style_font_size = char_style.and_then(|cs| cs.font_size);
let explicit_font_name =
rfonts_node.and_then(|rfonts| resolve_font_from_node_opt(rfonts, theme));
let char_style_font_name = char_style.and_then(|cs| cs.font_name.clone());
let font_size_from_default = explicit_font_size.is_none()
&& char_style_font_size.is_none()
&& self.font_size_is_doc_default;
let font_name_from_default = explicit_font_name.is_none()
&& char_style_font_name.is_none()
&& self.font_name_is_doc_default;
let underline_node = rpr.and_then(|n| wml(n, "u"));
let underline_val = underline_node.and_then(|u| u.attribute((WML_NS, "val")));
let resolved_font_size = explicit_font_size
.or(char_style_font_size)
.unwrap_or(self.font_size);
let resolved_color = rpr
.and_then(|n| wml_attr(n, "color"))
.and_then(parse_text_color)
.or_else(|| char_style.and_then(|cs| cs.color))
.or(self.color);
let legacy_outline = rpr.and_then(|n| wml_bool(n, "outline")).unwrap_or(false);
let legacy_shadow = rpr.and_then(|n| wml_bool(n, "shadow")).unwrap_or(false);
let legacy_emboss = rpr.and_then(|n| wml_bool(n, "emboss")).unwrap_or(false);
let legacy_imprint = rpr.and_then(|n| wml_bool(n, "imprint")).unwrap_or(false);
RunFormat {
font_size: explicit_font_size
.or(char_style_font_size)
.unwrap_or(self.font_size),
font_name: explicit_font_name
.or(char_style_font_name)
.unwrap_or_else(|| self.font_name.clone()),
east_asia_font_name: rfonts_node
.and_then(|rfonts| resolve_east_asia_font_from_node(rfonts, theme))
.or_else(|| char_style.and_then(|cs| cs.east_asia_font.clone()))
.or_else(|| self.east_asia_font.clone()),
bold: rpr
.and_then(|n| wml_bool(n, "b"))
.or_else(|| char_style.and_then(|cs| cs.bold))
.unwrap_or(self.bold),
italic: rpr
.and_then(|n| wml_bool(n, "i"))
.or_else(|| char_style.and_then(|cs| cs.italic))
.unwrap_or(self.italic),
underline: underline_val
.map(|v| v != "none")
.or_else(|| char_style.and_then(|cs| cs.underline))
.unwrap_or(self.underline),
double_underline: underline_val
.map(|v| v == "double")
.or_else(|| char_style.and_then(|cs| cs.double_underline))
.unwrap_or(self.double_underline),
strikethrough: rpr
.and_then(|n| wml_bool(n, "strike"))
.or_else(|| char_style.and_then(|cs| cs.strikethrough))
.unwrap_or(self.strikethrough),
dstrike: rpr
.and_then(|n| wml_bool(n, "dstrike"))
.unwrap_or(self.dstrike),
char_spacing: rpr
.and_then(parse_char_spacing)
.unwrap_or(self.char_spacing),
text_scale: rpr
.and_then(|n| wml_attr(n, "w"))
.and_then(|v| v.trim_end_matches('%').parse::<f32>().ok())
.unwrap_or(100.0),
caps: rpr
.and_then(|n| wml_bool(n, "caps"))
.or_else(|| char_style.and_then(|cs| cs.caps))
.unwrap_or(self.caps),
small_caps: rpr
.and_then(|n| wml_bool(n, "smallCaps"))
.or_else(|| char_style.and_then(|cs| cs.small_caps))
.unwrap_or(self.small_caps),
vanish: rpr
.and_then(|n| wml_bool(n, "vanish"))
.or_else(|| char_style.and_then(|cs| cs.vanish))
.unwrap_or(self.vanish),
color: rpr
.and_then(|n| wml_attr(n, "color"))
.and_then(parse_text_color)
.or_else(|| char_style.and_then(|cs| cs.color))
.or(self.color),
vertical_align: rpr
.and_then(|n| wml_attr(n, "vertAlign"))
.map(|v| match v {
"superscript" => VertAlign::Superscript,
"subscript" => VertAlign::Subscript,
_ => VertAlign::Baseline,
})
.unwrap_or(VertAlign::Baseline),
highlight: rpr
.and_then(|n| wml_attr(n, "highlight"))
.and_then(highlight_color)
.or_else(|| char_style.and_then(|cs| cs.highlight)),
shading: rpr
.and_then(parse_run_shd)
.or_else(|| char_style.and_then(|cs| cs.shading)),
border: rpr
.and_then(|n| wml(n, "bdr"))
.and_then(parse_one_border)
.or_else(|| char_style.and_then(|cs| cs.border.clone())),
kern_threshold: rpr
.and_then(|n| wml_attr(n, "kern"))
.and_then(|v| v.parse::<f32>().ok())
.map(|hp| hp / 2.0)
.or_else(|| char_style.and_then(|cs| cs.kern_threshold))
.or(self.kern_threshold),
char_style_id: char_style_id_str.map(|s| s.to_string()),
text_outline: rpr
.and_then(|n| parse_text_outline(n, theme))
.or_else(|| {
legacy_outline.then(|| TextOutline {
width_pt: (resolved_font_size * 0.014).max(0.3),
color: resolved_color.unwrap_or([0, 0, 0]),
})
})
.or_else(|| char_style.and_then(|cs| cs.text_outline.clone()))
.or_else(|| self.text_outline.clone()),
text_fill: rpr
.and_then(|n| parse_text_fill(n, theme))
.or_else(|| legacy_outline.then_some(TextFill::NoFill))
.or_else(|| char_style.and_then(|cs| cs.text_fill.clone()))
.or_else(|| self.text_fill.clone()),
text_shadow: rpr
.and_then(|n| parse_text_shadow(n, theme))
.or_else(|| {
legacy_text_shadow(
legacy_shadow,
legacy_emboss,
legacy_imprint,
resolved_font_size,
)
})
.or_else(|| char_style.and_then(|cs| cs.text_shadow.clone()))
.or_else(|| self.text_shadow.clone()),
text_glow: rpr
.and_then(|n| parse_text_glow(n, theme))
.or_else(|| char_style.and_then(|cs| cs.text_glow.clone()))
.or_else(|| self.text_glow.clone()),
lang: rpr
.and_then(|n| wml(n, "lang"))
.and_then(|n| n.attribute((WML_NS, "val")))
.map(|s| s.to_string()),
font_size_from_default,
font_name_from_default,
}
}
}
fn legacy_text_shadow(
shadow: bool,
emboss: bool,
imprint: bool,
font_size: f32,
) -> Option<TextShadow> {
let d = (font_size * 0.035).max(0.4);
if shadow {
Some(TextShadow { color: [128, 128, 128], offset_x: d, offset_y: -d, alpha: 1.0 })
} else if emboss || imprint {
Some(TextShadow { color: [128, 128, 128], offset_x: d, offset_y: -d, alpha: 1.0 })
} else {
None
}
}
fn ensure_nonempty_paragraph(
runs: &mut Vec<Run>,
ppr: Option<roxmltree::Node>,
defaults: &ParagraphRunDefaults,
theme: &ThemeFonts,
has_page_break_before: bool,
) {
if !runs.is_empty() || has_page_break_before {
return;
}
let mark_rpr = ppr.and_then(|ppr| wml(ppr, "rPr"));
let mark_font_size = mark_rpr.and_then(parse_font_size);
if let Some(mark_font_size) = mark_font_size {
let mark_font_name = mark_rpr
.and_then(|n| wml(n, "rFonts"))
.map(|rfonts| resolve_font_from_node(rfonts, theme, &defaults.font_name))
.unwrap_or_else(|| defaults.font_name.clone());
runs.push(Run {
font_size: mark_font_size,
font_name: mark_font_name,
bold: defaults.bold,
italic: defaults.italic,
..Run::default()
});
}
if runs.is_empty() {
runs.push(Run {
font_size: defaults.font_size,
font_name: defaults.font_name.clone(),
bold: defaults.bold,
italic: defaults.italic,
..Run::default()
});
}
}
fn split_run_by_script(run: Run) -> Vec<Run> {
let ea_font = match &run.east_asia_font_name {
Some(f) if f != &run.font_name => f.clone(),
_ => return vec![run],
};
let text = &run.text;
if text.is_empty() {
return vec![run];
}
let mut result: Vec<Run> = Vec::new();
let mut segment_start = 0;
let mut in_ea = false;
let mut first = true;
for (i, ch) in text.char_indices() {
let ch_is_ea = if ch.is_whitespace() {
in_ea
} else {
is_east_asian_char(ch)
};
if first {
in_ea = ch_is_ea;
first = false;
continue;
}
if ch_is_ea != in_ea {
let segment = &text[segment_start..i];
if !segment.is_empty() {
let mut sub = run.clone();
sub.text = segment.to_string();
if in_ea {
sub.font_name = ea_font.clone();
}
sub.east_asia_font_name = None;
result.push(sub);
}
segment_start = i;
in_ea = ch_is_ea;
}
}
let segment = &text[segment_start..];
if !segment.is_empty() {
let mut sub = run.clone();
sub.text = segment.to_string();
if in_ea {
sub.font_name = ea_font;
}
sub.east_asia_font_name = None;
result.push(sub);
}
if result.is_empty() {
let mut r = run;
r.east_asia_font_name = None;
vec![r]
} else {
result
}
}
fn is_comment_reference_run(node: roxmltree::Node) -> bool {
node.children().any(|c| {
c.tag_name().namespace() == Some(WML_NS) && c.tag_name().name() == "commentReference"
})
}
fn collect_run_nodes<'a>(
parent: roxmltree::Node<'a, 'a>,
rels: &HashMap<String, String>,
out: &mut Vec<(roxmltree::Node<'a, 'a>, Option<String>, bool, Vec<u32>)>,
active_comments: &mut Vec<u32>,
) {
for child in parent.children() {
let name = child.tag_name().name();
let ns = child.tag_name().namespace();
let is_wml = ns == Some(WML_NS);
if is_wml && name == "commentRangeStart" {
if let Some(id) = child
.attribute((WML_NS, "id"))
.and_then(|v| v.parse::<u32>().ok())
{
active_comments.push(id);
}
continue;
}
if is_wml && name == "commentRangeEnd" {
if let Some(id) = child
.attribute((WML_NS, "id"))
.and_then(|v| v.parse::<u32>().ok())
{
if let Some(pos) = active_comments.iter().rposition(|x| *x == id) {
active_comments.remove(pos);
}
}
continue;
}
if is_wml && name == "r" {
if is_comment_reference_run(child) {
continue;
}
out.push((child, None, false, active_comments.clone()));
} else if is_wml && name == "hyperlink" {
let has_rid = child.attribute((REL_NS, "id")).is_some();
let has_anchor = child.attribute((WML_NS, "anchor")).is_some();
let is_anchor_only = has_anchor && !has_rid;
let url = if is_anchor_only {
child.attribute((WML_NS, "anchor")).map(|a| format!("#{a}"))
} else {
child
.attribute((REL_NS, "id"))
.and_then(|rid| rels.get(rid))
.cloned()
};
for n in child
.children()
.filter(|n| n.tag_name().name() == "r" && n.tag_name().namespace() == Some(WML_NS))
{
if is_comment_reference_run(n) {
continue;
}
out.push((n, url.clone(), is_anchor_only, active_comments.clone()));
}
} else if is_wml && matches!(name, "ins" | "smartTag" | "customXml") {
collect_run_nodes(child, rels, out, active_comments);
} else if is_wml && name == "del" {
} else if is_wml && name == "sdt" {
if let Some(content) = wml(child, "sdtContent") {
collect_run_nodes(content, rels, out, active_comments);
}
} else if is_wml && name == "fldSimple" {
collect_run_nodes(child, rels, out, active_comments);
} else if ns == Some(MC_NS_TOP) && name == "AlternateContent" {
if let Some(branch) = mc_choice_or_fallback(child) {
collect_run_nodes(branch, rels, out, active_comments);
}
} else if ns == Some(MATH_NS) && name == "oMathPara" {
for om in child.children().filter(|n| {
n.tag_name().namespace() == Some(MATH_NS) && n.tag_name().name() == "oMath"
}) {
out.push((om, None, false, active_comments.clone()));
}
} else if ns == Some(MATH_NS) && name == "oMath" {
out.push((child, None, false, active_comments.clone()));
}
}
}
macro_rules! handle_drawing_result {
($result:expr, $fmt:expr, $runs:expr, $floating_images:expr, $textboxes:expr,
$inline_chart:expr, $smartart:expr, $connectors:expr) => {
match $result {
Some(RunDrawingResult::Inline(img)) => {
$runs.push(Run {
inline_image: Some(img),
..$fmt.minimal_run()
});
}
Some(RunDrawingResult::Floating(fi)) => $floating_images.push(fi),
Some(RunDrawingResult::TextBox(tb)) => $textboxes.push(tb),
Some(RunDrawingResult::Chart(ic)) => $inline_chart = Some(ic),
Some(RunDrawingResult::SmartArt(diagram)) => $smartart.push(diagram),
Some(RunDrawingResult::Connector(c)) => $connectors.push(c),
Some(RunDrawingResult::Group(items)) => {
for item in items {
match item {
RunDrawingResult::Inline(img) => {
$runs.push(Run {
inline_image: Some(img),
..$fmt.minimal_run()
});
}
RunDrawingResult::Floating(fi) => $floating_images.push(fi),
RunDrawingResult::TextBox(tb) => $textboxes.push(tb),
RunDrawingResult::Chart(ic) => $inline_chart = Some(ic),
RunDrawingResult::SmartArt(diagram) => $smartart.push(diagram),
RunDrawingResult::Connector(c) => $connectors.push(c),
RunDrawingResult::Group(_) => {}
}
}
}
None => {}
}
};
}
fn merge_compatible_runs(runs: Vec<Run>) -> Vec<Run> {
if runs.len() <= 1 {
return runs;
}
let mut result: Vec<Run> = Vec::with_capacity(runs.len());
for run in runs {
let can_merge = result.last().is_some_and(|prev| {
!prev.is_tab
&& !run.is_tab
&& !prev.is_line_break
&& !run.is_line_break
&& prev.inline_image.is_none()
&& run.inline_image.is_none()
&& prev.footnote_id.is_none()
&& run.footnote_id.is_none()
&& !prev.is_footnote_ref_mark
&& !run.is_footnote_ref_mark
&& prev.endnote_id.is_none()
&& run.endnote_id.is_none()
&& !prev.is_endnote_ref_mark
&& !run.is_endnote_ref_mark
&& prev.field_code.is_none()
&& run.field_code.is_none()
&& prev.font_name == run.font_name
&& prev.east_asia_font_name == run.east_asia_font_name
&& prev.font_size == run.font_size
&& prev.bold == run.bold
&& prev.italic == run.italic
&& prev.underline == run.underline
&& prev.strikethrough == run.strikethrough
&& prev.dstrike == run.dstrike
&& prev.char_spacing == run.char_spacing
&& prev.text_scale == run.text_scale
&& prev.caps == run.caps
&& prev.small_caps == run.small_caps
&& prev.vanish == run.vanish
&& prev.color == run.color
&& prev.highlight == run.highlight
&& prev.shading == run.shading
&& prev.border == run.border
&& prev.vertical_align == run.vertical_align
&& prev.kern_threshold == run.kern_threshold
&& prev.hyperlink_url == run.hyperlink_url
&& prev.text_outline == run.text_outline
&& prev.text_fill == run.text_fill
&& prev.text_shadow == run.text_shadow
&& prev.text_glow == run.text_glow
&& prev.lang == run.lang
&& prev.char_style_id == run.char_style_id
&& prev.comment_ids == run.comment_ids
});
if can_merge {
result.last_mut().unwrap().text.push_str(&run.text);
} else {
result.push(run);
}
}
result
}
pub(super) const MATH_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/math";
fn math_child<'a>(parent: roxmltree::Node<'a, 'a>, name: &str) -> Option<roxmltree::Node<'a, 'a>> {
parent
.children()
.find(|n| n.tag_name().namespace() == Some(MATH_NS) && n.tag_name().name() == name)
}
pub(super) fn display_math_alignment(para: roxmltree::Node) -> Option<crate::model::Alignment> {
use crate::model::Alignment;
let omp = para
.children()
.find(|n| n.tag_name().namespace() == Some(MATH_NS) && n.tag_name().name() == "oMathPara")?;
let jc = math_child(omp, "oMathParaPr")
.and_then(|pr| math_child(pr, "jc"))
.and_then(|j| j.attribute((MATH_NS, "val")));
Some(match jc {
Some("left") => Alignment::Left,
Some("right") => Alignment::Right,
_ => Alignment::Center,
})
}
fn omath_to_runs(
node: roxmltree::Node,
defaults: &ParagraphRunDefaults,
theme: &ThemeFonts,
vert: VertAlign,
out: &mut Vec<Run>,
) {
let push_lit = |out: &mut Vec<Run>, s: &str| {
let fmt = defaults.resolve_run_format(None, None, None, theme);
let mut r = fmt.text_run(s.to_string(), None);
r.vertical_align = vert;
r.is_math = true;
out.push(r);
};
for child in node.children() {
if child.tag_name().namespace() != Some(MATH_NS) {
continue;
}
match child.tag_name().name() {
"r" => {
let text: String = child
.children()
.filter(|n| {
n.tag_name().namespace() == Some(MATH_NS) && n.tag_name().name() == "t"
})
.filter_map(|t| t.text())
.collect();
if !text.is_empty() {
let fmt = defaults.resolve_run_format(wml(child, "rPr"), None, None, theme);
let mut r = fmt.text_run(text, None);
r.vertical_align = vert;
r.is_math = true;
out.extend(split_run_by_script(r).into_iter().map(|mut sr| {
sr.is_math = true;
sr
}));
}
}
"sSub" => {
if let Some(e) = math_child(child, "e") {
omath_to_runs(e, defaults, theme, vert, out);
}
if let Some(s) = math_child(child, "sub") {
omath_to_runs(s, defaults, theme, VertAlign::Subscript, out);
}
}
"sSup" => {
if let Some(e) = math_child(child, "e") {
omath_to_runs(e, defaults, theme, vert, out);
}
if let Some(s) = math_child(child, "sup") {
omath_to_runs(s, defaults, theme, VertAlign::Superscript, out);
}
}
"sSubSup" => {
if let Some(e) = math_child(child, "e") {
omath_to_runs(e, defaults, theme, vert, out);
}
if let Some(s) = math_child(child, "sub") {
omath_to_runs(s, defaults, theme, VertAlign::Subscript, out);
}
if let Some(s) = math_child(child, "sup") {
omath_to_runs(s, defaults, theme, VertAlign::Superscript, out);
}
}
"f" => {
if let Some(n) = math_child(child, "num") {
omath_to_runs(n, defaults, theme, vert, out);
}
push_lit(out, "/");
if let Some(d) = math_child(child, "den") {
omath_to_runs(d, defaults, theme, vert, out);
}
}
"rad" => {
push_lit(out, "√");
if let Some(e) = math_child(child, "e") {
push_lit(out, "(");
omath_to_runs(e, defaults, theme, vert, out);
push_lit(out, ")");
}
}
"d" => {
push_lit(out, "(");
if let Some(e) = math_child(child, "e") {
omath_to_runs(e, defaults, theme, vert, out);
}
push_lit(out, ")");
}
"nary" => {
let chr = math_child(child, "naryPr")
.and_then(|p| math_child(p, "chr"))
.and_then(|c| c.attribute((MATH_NS, "val")))
.unwrap_or("∫");
push_lit(out, chr);
if let Some(s) = math_child(child, "sub") {
omath_to_runs(s, defaults, theme, VertAlign::Subscript, out);
}
if let Some(s) = math_child(child, "sup") {
omath_to_runs(s, defaults, theme, VertAlign::Superscript, out);
}
if let Some(e) = math_child(child, "e") {
omath_to_runs(e, defaults, theme, vert, out);
}
}
_ => omath_to_runs(child, defaults, theme, vert, out),
}
}
}
pub(super) fn parse_runs<R: Read + Seek>(
para_node: roxmltree::Node,
ctx: &mut ParseContext<'_, R>,
) -> ParsedRuns {
let ppr = wml(para_node, "pPr");
let para_style_id = ppr
.and_then(|ppr| wml_attr(ppr, "pStyle"))
.unwrap_or(&ctx.styles.default_paragraph_style_id);
let para_style = ctx.styles.paragraph_styles.get(para_style_id);
let defaults = ParagraphRunDefaults::from_style(para_style, &ctx.styles.defaults);
let mut run_nodes: Vec<(roxmltree::Node, Option<String>, bool, Vec<u32>)> = Vec::new();
let mut active_comments: Vec<u32> = Vec::new();
collect_run_nodes(para_node, ctx.rels, &mut run_nodes, &mut active_comments);
let mut runs = Vec::new();
let mut floating_images: Vec<FloatingImage> = Vec::new();
let mut textboxes: Vec<Textbox> = Vec::new();
let mut connectors: Vec<ConnectorShape> = Vec::new();
let mut inline_chart: Option<InlineChart> = None;
let mut smartart: Vec<SmartArtDiagram> = Vec::new();
let mut horizontal_rule: Option<HorizontalRule> = None;
let mut has_page_break_after = false;
let mut page_break_before_content = false;
let mut has_column_break = false;
let mut has_clear_break = false;
let mut field_stack: Vec<FieldFrame> = Vec::new();
for (run_node, hyperlink_url, is_anchor_hyperlink, comment_ids) in run_nodes {
if run_node.tag_name().namespace() == Some(MATH_NS)
&& run_node.tag_name().name() == "oMath"
{
omath_to_runs(run_node, &defaults, ctx.theme, VertAlign::Baseline, &mut runs);
continue;
}
let runs_before = runs.len();
let rpr = wml(run_node, "rPr");
let char_style_id_str = rpr.and_then(|n| wml_attr(n, "rStyle"));
let char_style = if is_anchor_hyperlink {
None
} else {
char_style_id_str.and_then(|id| ctx.styles.character_styles.get(id))
};
let fmt = defaults.resolve_run_format(rpr, char_style, char_style_id_str, ctx.theme);
let flush_pending = |pending: &mut String, runs: &mut Vec<Run>| {
if !pending.is_empty() {
let run = fmt.text_run(std::mem::take(pending), hyperlink_url.clone());
runs.extend(split_run_by_script(run));
}
};
let mut pending_text = String::new();
for child in run_node.children() {
let child_ns = child.tag_name().namespace();
if child_ns == Some(MC_NS_TOP) && child.tag_name().name() == "AlternateContent" {
let choice = child.children().find(|n| {
n.tag_name().namespace() == Some(MC_NS_TOP) && n.tag_name().name() == "Choice"
});
if let Some(branch) = choice {
for drawing in branch.children().filter(|n| {
n.tag_name().namespace() == Some(WML_NS) && n.tag_name().name() == "drawing"
}) {
let result =
parse_run_drawing(drawing, ctx);
handle_drawing_result!(
result,
fmt,
runs,
floating_images,
textboxes,
inline_chart,
smartart,
connectors
);
}
} else if let Some(branch) = child.children().find(|n| {
n.tag_name().namespace() == Some(MC_NS_TOP) && n.tag_name().name() == "Fallback"
}) {
for pict in branch.descendants().filter(|n| {
n.tag_name().namespace() == Some(WML_NS) && n.tag_name().name() == "pict"
}) {
if let Some(tb) =
parse_textbox_from_vml(pict, ctx)
{
textboxes.push(tb);
}
}
}
continue;
}
if child_ns != Some(WML_NS) {
continue;
}
match child.tag_name().name() {
"fldChar" => match child.attribute((WML_NS, "fldCharType")) {
Some("begin") => {
let parent_visible =
field_stack.last().map_or(true, |f| f.seen_sep);
if field_stack.is_empty() {
flush_pending(&mut pending_text, &mut runs);
}
field_stack.push(FieldFrame {
seen_sep: false,
visible: parent_visible,
instr: String::new(),
result: String::new(),
});
}
Some("separate") => {
if let Some(f) = field_stack.last_mut() {
f.seen_sep = true;
}
}
Some("end") => {
if let Some(f) = field_stack.pop() {
if f.visible {
let keyword =
f.instr.split_whitespace().next().unwrap_or("");
let fc = if keyword.eq_ignore_ascii_case("PAGE") {
Some(FieldCode::Page)
} else if keyword.eq_ignore_ascii_case("NUMPAGES") {
Some(FieldCode::NumPages)
} else if keyword.eq_ignore_ascii_case("STYLEREF") {
parse_styleref_arg(&f.instr).map(FieldCode::StyleRef)
} else if keyword.eq_ignore_ascii_case("PAGEREF") {
f.instr.split_whitespace().nth(1)
.map(|s| FieldCode::PageRef(s.to_string()))
} else {
None
};
if let Some(code) = fc {
runs.push(Run {
text: f.result,
field_code: Some(code),
hyperlink_url: hyperlink_url.clone(),
..fmt.styled_run()
});
}
}
}
}
_ => {}
},
"instrText" => {
if let Some(f) = field_stack.last_mut() {
if !f.seen_sep {
if let Some(t) = child.text() {
f.instr.push_str(t);
}
}
}
}
"t" => {
let visible = field_stack.last().map_or(true, |f| f.seen_sep);
let dyn_result = field_stack
.last()
.is_some_and(|f| f.seen_sep && is_dynamic_field(&f.instr));
if dyn_result {
if let Some(t) = child.text() {
field_stack.last_mut().unwrap().result.push_str(t);
}
} else if visible {
if let Some(t) = child.text() {
pending_text.push_str(&t.replace('\n', " "));
}
}
}
"noBreakHyphen" => {
pending_text.push('-');
}
"tab" => {
let visible = field_stack.last().map_or(true, |f| f.seen_sep);
let dyn_result = field_stack
.last()
.is_some_and(|f| f.seen_sep && is_dynamic_field(&f.instr));
if visible && !dyn_result {
flush_pending(&mut pending_text, &mut runs);
runs.push(fmt.tab_run());
}
}
"ptab" => {
let visible = field_stack.last().map_or(true, |f| f.seen_sep);
let dyn_result = field_stack
.last()
.is_some_and(|f| f.seen_sep && is_dynamic_field(&f.instr));
if visible && !dyn_result {
let alignment = match child.attribute((WML_NS, "alignment")) {
Some("center") => TabAlignment::Center,
Some("right") => TabAlignment::Right,
_ => TabAlignment::Left,
};
flush_pending(&mut pending_text, &mut runs);
runs.push(fmt.ptab_run(alignment));
}
}
"br" if field_stack.is_empty() => match child.attribute((WML_NS, "type")) {
Some("page") => {
if runs.is_empty() && pending_text.is_empty() {
page_break_before_content = true;
} else {
has_page_break_after = true;
}
}
Some("column") => has_column_break = true,
_ => {
if child.attribute((WML_NS, "clear")) == Some("all") {
has_clear_break = true;
}
flush_pending(&mut pending_text, &mut runs);
runs.push(Run {
is_line_break: true,
..fmt.minimal_run()
});
}
},
"drawing" if !field_stack.is_empty() => {}
"drawing" => {
flush_pending(&mut pending_text, &mut runs);
let result = parse_run_drawing(child, ctx);
handle_drawing_result!(
result,
fmt,
runs,
floating_images,
textboxes,
inline_chart,
smartart,
connectors
);
}
"pict" if field_stack.is_empty() => {
if let Some(hr) = parse_vml_horizontal_rule(child) {
horizontal_rule = Some(hr);
} else if let Some(tb) =
parse_textbox_from_vml(child, ctx)
{
textboxes.push(tb);
}
}
"object" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
if let Some(fi) = parse_object_floating_image(child, ctx) {
floating_images.push(fi);
} else if let Some(img) = parse_object_inline_image(child, ctx) {
runs.push(Run {
inline_image: Some(img),
..fmt.minimal_run()
});
}
}
"footnoteReference" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
if let Some(id) = child
.attribute((WML_NS, "id"))
.and_then(|v| v.parse::<u32>().ok())
{
runs.push(Run {
footnote_id: Some(id),
..fmt.superscript_run()
});
}
}
"footnoteRef" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
runs.push(Run {
is_footnote_ref_mark: true,
..fmt.superscript_run()
});
}
"endnoteReference" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
if let Some(id) = child
.attribute((WML_NS, "id"))
.and_then(|v| v.parse::<u32>().ok())
{
runs.push(Run {
endnote_id: Some(id),
..fmt.superscript_run()
});
}
}
"endnoteRef" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
runs.push(Run {
is_endnote_ref_mark: true,
..fmt.superscript_run()
});
}
"sym" if field_stack.is_empty() => {
flush_pending(&mut pending_text, &mut runs);
let sym_font = child.attribute((WML_NS, "font")).unwrap_or(&fmt.font_name);
if let Some(ch) = child
.attribute((WML_NS, "char"))
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.and_then(char::from_u32)
{
runs.push(Run {
text: ch.to_string(),
font_name: sym_font.to_string(),
font_size: fmt.font_size,
bold: fmt.bold,
italic: fmt.italic,
color: fmt.color,
underline: fmt.underline,
strikethrough: fmt.strikethrough,
char_spacing: fmt.char_spacing,
..Run::default()
});
}
}
_ => {}
}
}
if !pending_text.is_empty() {
let run = fmt.text_run(pending_text, hyperlink_url.clone());
runs.extend(split_run_by_script(run));
}
if !comment_ids.is_empty() {
for r in &mut runs[runs_before..] {
r.comment_ids = comment_ids.clone();
}
}
}
let has_page_break_before = ppr
.and_then(|ppr| wml_bool(ppr, "pageBreakBefore"))
.unwrap_or(false)
|| page_break_before_content;
ensure_nonempty_paragraph(&mut runs, ppr, &defaults, ctx.theme, has_page_break_before);
let runs = merge_compatible_runs(runs);
ParsedRuns {
runs,
has_page_break_before,
has_explicit_page_break_before: page_break_before_content,
has_page_break_after,
has_column_break,
has_clear_break,
floating_images,
textboxes,
connectors,
inline_chart,
smartart,
horizontal_rule,
}
}
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
fn parse_vml_horizontal_rule(pict_node: roxmltree::Node) -> Option<HorizontalRule> {
let shape = pict_node.children().find(|n| {
n.tag_name().namespace() == Some(VML_NS)
&& matches!(n.tag_name().name(), "rect" | "shape")
})?;
let is_hr = shape
.attribute((OFFICE_NS, "hr"))
.is_some_and(|v| v == "t" || v == "true");
if !is_hr {
return None;
}
let style_str = shape.attribute("style").unwrap_or("");
let mut height_pt = 1.5_f32;
for part in style_str.split(';') {
if let Some((key, val)) = part.trim().split_once(':') {
if key.trim() == "height" {
height_pt = parse_pt(val).unwrap_or(1.5);
}
}
}
let fill_color = shape
.attribute("fillcolor")
.and_then(|c| {
let hex = c.strip_prefix('#').unwrap_or(c);
parse_hex_color(hex)
})
.unwrap_or([0xa0, 0xa0, 0xa0]);
let hrpct = shape
.attribute((OFFICE_NS, "hrpct"))
.and_then(|v| v.parse::<f32>().ok())
.map(|v| v / 10.0);
let width_pct = hrpct.unwrap_or(100.0);
let is_standard = shape
.attribute((OFFICE_NS, "hrstd"))
.is_some_and(|v| v == "t" || v == "true");
Some(HorizontalRule {
height_pt,
fill_color,
width_pct,
is_standard,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn run(text: &str, shading: Option<[u8; 3]>, highlight: Option<[u8; 3]>) -> Run {
Run {
text: text.to_string(),
font_name: "Arial".into(),
font_size: 11.0,
shading,
highlight,
..Run::default()
}
}
#[test]
fn merge_compatible_runs_preserves_shading_boundary() {
let runs = vec![
run("before ", None, None),
run("shaded", Some([255, 244, 163]), None),
run(" after", None, None),
];
let merged = merge_compatible_runs(runs);
assert_eq!(merged.len(), 3, "runs differing in shading must not merge");
assert_eq!(merged[1].shading, Some([255, 244, 163]));
}
#[test]
fn collect_run_nodes_unwraps_fldsimple() {
let ns = WML_NS;
let xml = format!(
r#"<w:p xmlns:w="{ns}">
<w:fldSimple w:instr=" FILLIN "x" \d OFFICIAL ">
<w:r><w:t>OFFICIAL</w:t></w:r>
</w:fldSimple>
</w:p>"#
);
let doc = roxmltree::Document::parse(&xml).unwrap();
let rels = HashMap::new();
let mut out = Vec::new();
let mut active = Vec::new();
collect_run_nodes(doc.root_element(), &rels, &mut out, &mut active);
assert_eq!(out.len(), 1, "fldSimple's inner w:r must be collected");
let t_text = out[0]
.0
.children()
.find(|n| n.tag_name().name() == "t" && n.tag_name().namespace() == Some(WML_NS))
.and_then(|n| n.text());
assert_eq!(t_text, Some("OFFICIAL"));
}
#[test]
fn merge_compatible_runs_merges_same_shading() {
let c = Some([200, 250, 204]);
let runs = vec![
run("green ", c, None),
run("continues", c, None),
];
let merged = merge_compatible_runs(runs);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].text, "green continues");
assert_eq!(merged[0].shading, c);
}
}