use std::borrow::Cow;
use roxmltree::{Document, Node};
pub(super) const MC_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
const SUPPORTED: &[&str] = &[
"http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
"http://schemas.microsoft.com/office/word/2010/wordprocessingGroup",
"http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas",
"http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
"http://schemas.microsoft.com/office/word/2010/wordml",
"http://schemas.microsoft.com/office/drawing/2010/main",
];
const MAX_DEPTH: usize = 8;
pub(super) fn resolve_alternate_content(xml: &str) -> Cow<'_, str> {
if !xml.contains("AlternateContent") {
return Cow::Borrowed(xml);
}
let mut current = Cow::Borrowed(xml);
for _ in 0..MAX_DEPTH {
let Ok(doc) = Document::parse(¤t) else {
return current;
};
let edits = outermost_edits(&doc);
if edits.is_empty() {
return current;
}
let mut out = String::with_capacity(current.len());
let mut pos = 0;
for (range, replacement) in edits {
out.push_str(¤t[pos..range.start]);
out.push_str(&replacement);
pos = range.end;
}
out.push_str(¤t[pos..]);
current = Cow::Owned(out);
}
current
}
fn outermost_edits(doc: &Document) -> Vec<(std::ops::Range<usize>, String)> {
let is_alt = |n: Node| n.is_element() && n.tag_name() == (MC_NS, "AlternateContent").into();
doc.descendants()
.filter(|n| is_alt(*n) && !n.ancestors().skip(1).any(is_alt))
.map(|alt| (alt.range(), branch_text(doc, alt)))
.collect()
}
fn branch_text(doc: &Document, alt: Node) -> String {
let Some(branch) = select_branch(alt) else {
return String::new();
};
let src = doc.input_text();
let (Some(first), Some(last)) = (branch.first_child(), branch.last_child()) else {
return String::new();
};
let inner = first.range().start..last.range().end;
let outside = alt.parent().unwrap_or_else(|| doc.root());
let hoisted: Vec<String> = branch
.namespaces()
.filter(|ns| outside.lookup_namespace_uri(ns.name()) != Some(ns.uri()))
.map(|ns| match ns.name() {
Some(p) => format!(" xmlns:{p}=\"{}\"", ns.uri()),
None => format!(" xmlns=\"{}\"", ns.uri()),
})
.collect();
if hoisted.is_empty() {
return src[inner].to_string();
}
let mut text = src[inner.clone()].to_string();
let decls = hoisted.concat();
let mut tops: Vec<Node> = branch.children().filter(Node::is_element).collect();
tops.reverse();
for el in tops {
let at = qname_end(src, el.range().start) - inner.start;
text.insert_str(at, &decls);
}
text
}
fn qname_end(src: &str, start: usize) -> usize {
let tag = &src[start + 1..];
let len = tag
.find(|c: char| c.is_ascii_whitespace() || c == '/' || c == '>')
.unwrap_or(tag.len());
start + 1 + len
}
fn select_branch<'a, 'i>(alt: Node<'a, 'i>) -> Option<Node<'a, 'i>> {
let mc = |n: &Node, name: &str| n.is_element() && n.tag_name() == (MC_NS, name).into();
alt.children()
.filter(|n| mc(n, "Choice"))
.find(|choice| {
choice
.attribute((MC_NS, "Requires"))
.or_else(|| choice.attribute("Requires"))
.is_none_or(|req| {
req.split_whitespace().all(|prefix| {
choice
.lookup_namespace_uri(Some(prefix))
.is_some_and(|uri| SUPPORTED.contains(&uri))
})
})
})
.or_else(|| alt.children().find(|n| mc(n, "Fallback")))
}
#[cfg(test)]
mod tests {
use super::*;
const HEAD: &str = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" mc:Ignorable="wps wpi"><w:body>"#;
const TAIL: &str = "</w:body></w:document>";
fn paragraphs(xml: &str) -> Vec<String> {
let doc = Document::parse(xml).unwrap();
doc.descendants()
.filter(|n| n.has_tag_name("t"))
.map(|t| t.text().unwrap_or("").to_string())
.collect()
}
#[test]
fn supported_choice_wins_and_fallback_is_gone() {
let xml = format!(
"{HEAD}<w:p><w:r><w:t>before</w:t></w:r></w:p>\
<mc:AlternateContent><mc:Choice Requires=\"wps\">\
<w:p><w:r><w:t>choice</w:t></w:r></w:p><w:p><w:r><w:t>twice</w:t></w:r></w:p>\
<w:p><w:r><w:t>twice</w:t></w:r></w:p></mc:Choice>\
<mc:Fallback><w:p><w:r><w:t>fallback</w:t></w:r></w:p></mc:Fallback>\
</mc:AlternateContent><w:p><w:r><w:t>after</w:t></w:r></w:p>{TAIL}"
);
let out = resolve_alternate_content(&xml);
assert!(!out.contains("AlternateContent") && !out.contains("fallback"));
assert_eq!(
paragraphs(&out),
["before", "choice", "twice", "twice", "after"]
);
let doc = Document::parse(&out).unwrap();
let body = doc.descendants().find(|n| n.has_tag_name("body")).unwrap();
assert_eq!(body.children().filter(Node::is_element).count(), 5);
}
#[test]
fn unsupported_requirement_takes_the_fallback() {
let xml = format!(
"{HEAD}<mc:AlternateContent><mc:Choice Requires=\"wpi\">\
<w:p><w:r><w:t>ink</w:t></w:r></w:p></mc:Choice>\
<mc:Choice Requires=\"wps unknown\"><w:p><w:r><w:t>partly</w:t></w:r></w:p></mc:Choice>\
<mc:Fallback><w:p><w:r><w:t>picture</w:t></w:r></w:p></mc:Fallback>\
</mc:AlternateContent>{TAIL}"
);
assert_eq!(paragraphs(&resolve_alternate_content(&xml)), ["picture"]);
let xml = format!(
"{HEAD}<mc:AlternateContent><mc:Choice Requires=\"wpi\">\
<w:p><w:r><w:t>ink</w:t></w:r></w:p></mc:Choice></mc:AlternateContent>\
<w:p><w:r><w:t>kept</w:t></w:r></w:p>{TAIL}"
);
assert_eq!(paragraphs(&resolve_alternate_content(&xml)), ["kept"]);
}
#[test]
fn nested_alternatives_and_local_namespaces_survive() {
let xml = format!(
"{HEAD}<w:p><w:r><mc:AlternateContent xmlns:x=\"urn:x\">\
<mc:Choice Requires=\"wps\" xmlns:y=\"urn:y\"><x:a y:k=\"1\"><w:t>outer</w:t>\
<mc:AlternateContent><mc:Choice Requires=\"wps\"><w:t>inner</w:t></mc:Choice>\
<mc:Fallback><w:t>no</w:t></mc:Fallback></mc:AlternateContent></x:a></mc:Choice>\
<mc:Fallback><w:t>no</w:t></mc:Fallback></mc:AlternateContent></w:r></w:p>{TAIL}"
);
let out = resolve_alternate_content(&xml);
assert!(!out.contains("AlternateContent"), "{out}");
assert_eq!(paragraphs(&out), ["outer", "inner"]);
let doc = Document::parse(&out).unwrap();
let a = doc.descendants().find(|n| n.has_tag_name("a")).unwrap();
assert_eq!(a.tag_name().namespace(), Some("urn:x"));
assert_eq!(a.attribute(("urn:y", "k")), Some("1"));
}
#[test]
fn untouched_without_alternatives() {
let xml = format!("{HEAD}<w:p/>{TAIL}");
assert!(matches!(resolve_alternate_content(&xml), Cow::Borrowed(_)));
assert!(matches!(
resolve_alternate_content("<a><b"),
Cow::Borrowed(_)
));
}
}