#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaggedRange {
pub start: usize,
pub end: usize,
pub tag_name: String,
}
pub(crate) fn tag_token_end(xml: &str, start: usize) -> Option<usize> {
let mut quote = None;
for (offset, byte) in xml.as_bytes().iter().copied().enumerate().skip(start) {
if let Some(active) = quote {
if byte == active {
quote = None;
}
} else if byte == b'\'' || byte == b'"' {
quote = Some(byte);
} else if byte == b'>' {
return Some(offset + 1);
}
}
None
}
fn visit_direct_child_tags(xml: &str, mut visit: impl FnMut(usize, usize, &str) -> bool) {
let Some(root_start) = xml.find('<') else {
return;
};
let Some(mut cursor) = tag_token_end(xml, root_start) else {
return;
};
let mut depth = 0usize;
let mut pending: Option<(&str, usize)> = None;
while let Some(relative_start) = xml[cursor..].find('<') {
let start = cursor + relative_start;
if xml[start..].starts_with("<!--") {
let Some(end) = xml[start..].find("-->") else {
break;
};
cursor = start + end + 3;
continue;
}
if xml[start..].starts_with("<![CDATA[") {
let Some(end) = xml[start..].find("]]>") else {
break;
};
cursor = start + end + 3;
continue;
}
let Some(end) = tag_token_end(xml, start) else {
break;
};
let token = &xml[start..end];
cursor = end;
if token.starts_with("<?") || token.starts_with("<!") {
continue;
}
if token.starts_with("</") {
if depth == 0 {
break;
}
depth -= 1;
if depth == 0 {
if let Some((name, start)) = pending.take() {
if !visit(start, end, name) {
break;
}
}
}
} else {
if depth == 0 {
let name = token[1..]
.split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
.next()
.unwrap_or_default();
if token.ends_with("/>") {
if !visit(start, end, name) {
break;
}
} else {
pending = Some((name, start));
}
}
if !token.ends_with("/>") {
depth += 1;
}
}
}
}
pub fn extract_direct_child_tag_ranges(xml: &str) -> Vec<TaggedRange> {
if let Some(children) = crate::xml_index::children(xml) {
return children;
}
let mut children = Vec::new();
visit_direct_child_tags(xml, |start, end, name| {
children.push(TaggedRange {
start,
end,
tag_name: name.to_owned(),
});
true
});
children
}
pub(crate) fn direct_child_tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
if let Some(child) = crate::xml_index::child(xml, name) {
return child;
}
let mut found = None;
visit_direct_child_tags(xml, |start, end, tag| {
if tag.eq_ignore_ascii_case(name) {
found = Some(&xml[start..end]);
false
} else {
true
}
});
found
}
pub(crate) fn without_direct_child_tags<'a>(xml: &'a str, name: &str) -> std::borrow::Cow<'a, str> {
if direct_child_tag(xml, name).is_none() {
return std::borrow::Cow::Borrowed(xml);
}
let mut result = std::borrow::Cow::Borrowed(xml);
for range in extract_direct_child_tag_ranges(xml)
.into_iter()
.rev()
.filter(|range| range.tag_name.eq_ignore_ascii_case(name))
{
result.to_mut().replace_range(range.start..range.end, "");
}
result
}
pub type ParagraphAlignment = &'static str;
fn is_word_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn eq_ignore_ascii_case(left: &[u8], right: &[u8]) -> bool {
left.len() == right.len()
&& left
.iter()
.zip(right.iter())
.all(|(left_byte, right_byte)| left_byte.eq_ignore_ascii_case(right_byte))
}
fn starts_with_ignore_ascii_case(haystack: &str, prefix: &str) -> bool {
let haystack_bytes = haystack.as_bytes();
let prefix_bytes = prefix.as_bytes();
haystack_bytes.len() >= prefix_bytes.len()
&& eq_ignore_ascii_case(&haystack_bytes[..prefix_bytes.len()], prefix_bytes)
}
fn has_word_boundary_after(xml: &str, index: usize) -> bool {
xml.as_bytes()
.get(index)
.is_none_or(|byte| !is_word_char(*byte))
}
fn try_match_closing_tag(xml: &str, start: usize, tag_name: &str) -> Option<usize> {
if !xml.as_bytes().get(start).is_some_and(|byte| *byte == b'<') {
return None;
}
let after_lt = start + 1;
if !starts_with_ignore_ascii_case(&xml[after_lt..], "/") {
return None;
}
let tag_start = after_lt + 1;
let tag_name_bytes = tag_name.as_bytes();
let xml_bytes = xml.as_bytes();
if tag_start + tag_name_bytes.len() > xml_bytes.len() {
return None;
}
if !eq_ignore_ascii_case(
&xml_bytes[tag_start..tag_start + tag_name_bytes.len()],
tag_name_bytes,
) {
return None;
}
let after_tag = tag_start + tag_name_bytes.len();
if xml_bytes.get(after_tag) != Some(&b'>') {
return None;
}
Some(after_tag + 1)
}
fn try_match_opening_tag(xml: &str, start: usize, tag_name: &str) -> Option<usize> {
if !xml.as_bytes().get(start).is_some_and(|byte| *byte == b'<') {
return None;
}
let tag_start = start + 1;
if xml.as_bytes().get(tag_start) == Some(&b'/') {
return None;
}
let tag_name_bytes = tag_name.as_bytes();
let xml_bytes = xml.as_bytes();
if tag_start + tag_name_bytes.len() > xml_bytes.len() {
return None;
}
if !eq_ignore_ascii_case(
&xml_bytes[tag_start..tag_start + tag_name_bytes.len()],
tag_name_bytes,
) {
return None;
}
let after_tag = tag_start + tag_name_bytes.len();
if !has_word_boundary_after(xml, after_tag) {
return None;
}
tag_token_end(xml, after_tag)
}
fn is_self_closing_tag(token: &str) -> bool {
let bytes = token.as_bytes();
let mut index = bytes.len();
while index > 0 && bytes[index - 1].is_ascii_whitespace() {
index -= 1;
}
index >= 2 && bytes[index - 2] == b'/' && bytes[index - 1] == b'>'
}
fn find_tag_tokens<'a>(
xml: &'a str,
tag_name: &'a str,
) -> impl Iterator<Item = (usize, usize, bool)> + 'a {
let mut index = 0;
std::iter::from_fn(move || {
while let Some(offset) = xml[index..].find('<') {
let start = index + offset;
if let Some(end) = try_match_closing_tag(xml, start, tag_name) {
index = end;
return Some((start, end, true));
}
if let Some(end) = try_match_opening_tag(xml, start, tag_name) {
index = end;
return Some((start, end, false));
}
index = start + 1;
}
None
})
}
fn decode_xml_entity(entity: &str) -> Option<String> {
match entity {
"lt" => return Some("<".to_string()),
"gt" => return Some(">".to_string()),
"quot" => return Some("\"".to_string()),
"apos" => return Some("'".to_string()),
"amp" => return Some("&".to_string()),
_ => {}
}
let (digits, radix) = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"))
.map(|digits| (digits, 16))
.or_else(|| entity.strip_prefix('#').map(|digits| (digits, 10)))?;
if digits.is_empty() {
return None;
}
let code_point = u32::from_str_radix(digits, radix).ok()?;
if code_point == 0 {
return None;
}
char::from_u32(code_point).map(|character| character.to_string())
}
pub fn decode_xml_entities(text: &str) -> String {
let Some(first_ampersand) = text.find('&') else {
return text.to_string();
};
let mut decoded = String::with_capacity(text.len());
decoded.push_str(&text[..first_ampersand]);
let mut remaining = &text[first_ampersand..];
while let Some(ampersand_index) = remaining.find('&') {
decoded.push_str(&remaining[..ampersand_index]);
let after_ampersand = &remaining[ampersand_index + 1..];
if let Some(semicolon_index) = after_ampersand.find(';') {
let entity = &after_ampersand[..semicolon_index];
if let Some(value) = decode_xml_entity(entity) {
decoded.push_str(&value);
remaining = &after_ampersand[semicolon_index + 1..];
continue;
}
}
decoded.push('&');
remaining = after_ampersand;
}
decoded.push_str(remaining);
decoded
}
pub fn extract_balanced_tag_ranges(xml: &str, tag_name: &str) -> Vec<TagRange> {
if let Some(ranges) = crate::xml_index::ranges(xml, tag_name) {
return ranges;
}
let mut ranges = Vec::new();
let mut start_stack = Vec::new();
for (token_start, token_end, is_closing) in find_tag_tokens(xml, tag_name) {
let token_xml = &xml[token_start..token_end];
if is_closing {
let Some(start) = start_stack.pop() else {
continue;
};
if start_stack.is_empty() {
ranges.push(TagRange {
start,
end: token_end,
});
}
continue;
}
if is_self_closing_tag(token_xml) {
if start_stack.is_empty() {
ranges.push(TagRange {
start: token_start,
end: token_end,
});
}
continue;
}
start_stack.push(token_start);
}
ranges
}
pub fn extract_balanced_tag_blocks(xml: &str, tag_name: &str) -> Vec<String> {
extract_balanced_tag_ranges(xml, tag_name)
.into_iter()
.map(|range| xml[range.start..range.end].to_string())
.collect()
}
pub(crate) fn extract_balanced_tag_slices<'a>(xml: &'a str, tag_name: &str) -> Vec<&'a str> {
extract_balanced_tag_ranges(xml, tag_name)
.into_iter()
.map(|range| &xml[range.start..range.end])
.collect()
}
pub fn extract_balanced_tag_blocks_in_order(xml: &str, tag_names: &[&str]) -> Vec<TaggedRange> {
let mut ranges = Vec::new();
for tag_name in tag_names {
for range in extract_balanced_tag_ranges(xml, tag_name) {
ranges.push(TaggedRange {
start: range.start,
end: range.end,
tag_name: (*tag_name).to_string(),
});
}
}
ranges.sort_by(|left, right| {
left.start
.cmp(&right.start)
.then_with(|| left.end.cmp(&right.end))
});
let mut top_level_ranges: Vec<TaggedRange> = Vec::new();
let mut covered_end = 0;
for range in ranges {
if range.end <= covered_end {
continue;
}
covered_end = range.end;
top_level_ranges.push(range);
}
top_level_ranges
}
fn find_namespaced_tag_attributes<'a>(xml: &'a str, tag_name: &str) -> Option<&'a str> {
let mut name_buffer = [0u8; 128];
let qualified_name = if tag_name.len() <= name_buffer.len() - 2 {
name_buffer[..2].copy_from_slice(b"w:");
name_buffer[2..tag_name.len() + 2].copy_from_slice(tag_name.as_bytes());
std::borrow::Cow::Borrowed(std::str::from_utf8(&name_buffer[..tag_name.len() + 2]).ok()?)
} else {
std::borrow::Cow::Owned(format!("w:{tag_name}"))
};
let prefix_len = qualified_name.len() + 1;
if let Some(tag) = crate::xml_index::tag(xml, &qualified_name) {
return tag.map(|tag| tag[prefix_len..tag.len() - 1].trim_end_matches('/'));
}
let mut index = 0;
while index < xml.len() {
if xml.as_bytes()[index] != b'<' {
index += 1;
continue;
}
if !starts_with_ignore_ascii_case(&xml[index + 1..], &qualified_name) {
index += 1;
continue;
}
let after_prefix = index + prefix_len;
if !has_word_boundary_after(xml, after_prefix) {
index += 1;
continue;
}
let mut cursor = after_prefix;
let xml_bytes = xml.as_bytes();
while cursor < xml_bytes.len() {
let byte = xml_bytes[cursor];
if byte == b'>' {
let attributes = xml[after_prefix..cursor].trim_end_matches('/');
return Some(attributes);
}
cursor += 1;
}
return None;
}
None
}
fn strip_quoted_attribute(text: &str, attribute: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut index = 0;
let bytes = text.as_bytes();
while index < bytes.len() {
let remaining = &text[index..];
let is_match = starts_with_ignore_ascii_case(remaining, attribute)
&& has_word_boundary_before(text, index)
&& bytes
.get(index + attribute.len())
.is_some_and(|byte| *byte == b'=')
&& bytes
.get(index + attribute.len() + 1)
.is_some_and(|byte| *byte == b'"');
if is_match {
let quote_start = index + attribute.len() + 2;
let mut quote_end = quote_start;
while quote_end < bytes.len() && bytes[quote_end] != b'"' {
quote_end += 1;
}
if quote_end < bytes.len() {
index = quote_end + 1;
continue;
}
}
result.push(bytes[index] as char);
index += 1;
}
result
}
fn has_word_boundary_before(text: &str, index: usize) -> bool {
if index == 0 {
return true;
}
text.as_bytes()
.get(index - 1)
.is_none_or(|byte| !is_word_char(*byte))
}
fn parse_on_off_value(attributes: &str) -> bool {
let Some(value) = get_attribute_ref(attributes, "w:val") else {
return true;
};
value != "0"
&& !value.eq_ignore_ascii_case("false")
&& !value.eq_ignore_ascii_case("none")
&& !value.eq_ignore_ascii_case("off")
}
pub fn parse_on_off_attribute(xml: &str, tag_name: &str) -> Option<bool> {
let attributes = find_namespaced_tag_attributes(xml, tag_name)?;
Some(parse_on_off_value(&attributes))
}
pub fn parse_underline_attribute(xml: &str) -> Option<bool> {
let attributes = find_namespaced_tag_attributes(xml, "u")?;
if get_attribute_ref(attributes, "w:val").is_some() {
return Some(parse_on_off_value(&attributes));
}
if attributes
.bytes()
.all(|byte| byte.is_ascii_whitespace() || byte == b'/')
{
return Some(true);
}
let compact_attributes: String = attributes
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect();
let compact_attributes = compact_attributes.trim_end_matches('/');
if compact_attributes.is_empty() {
return Some(true);
}
let non_decoration_attributes = strip_quoted_attribute(
&strip_quoted_attribute(
&strip_quoted_attribute(
&strip_quoted_attribute(&compact_attributes, "w:themeShade"),
"w:themeTint",
),
"w:themeColor",
),
"w:color",
)
.trim_end_matches('/')
.to_string();
if non_decoration_attributes.is_empty() {
return None;
}
Some(true)
}
pub fn normalize_alignment(raw_alignment: Option<&str>) -> Option<ParagraphAlignment> {
let value = raw_alignment?;
let value = value.to_ascii_lowercase();
if value == "both" || value == "distribute" || value == "thaidistribute" {
return Some("justify");
}
if matches!(value.as_str(), "left" | "center" | "right" | "justify") {
return Some(match value.as_str() {
"left" => "left",
"center" => "center",
"right" => "right",
"justify" => "justify",
_ => unreachable!(),
});
}
None
}
pub fn get_attribute(tag_xml: &str, attribute: &str) -> Option<String> {
get_attribute_ref(tag_xml, attribute).map(str::to_owned)
}
pub(crate) fn get_attribute_ref<'a>(tag_xml: &'a str, attribute: &str) -> Option<&'a str> {
let bytes = tag_xml.as_bytes();
let mut cursor = 0;
while let Some(offset) = memchr::memchr(b'=', &bytes[cursor..]) {
let equals = cursor + offset;
let mut name_end = equals;
while name_end > 0 && bytes[name_end - 1].is_ascii_whitespace() {
name_end -= 1;
}
let mut name_start = name_end;
while name_start > 0
&& !bytes[name_start - 1].is_ascii_whitespace()
&& !matches!(bytes[name_start - 1], b'<' | b'>' | b'/' | b'"' | b'\'')
{
name_start -= 1;
}
let mut quote_start = equals + 1;
while quote_start < bytes.len() && bytes[quote_start].is_ascii_whitespace() {
quote_start += 1;
}
let Some("e) = bytes.get(quote_start) else {
return None;
};
if quote != b'"' && quote != b'\'' {
cursor = equals + 1;
continue;
}
let start = quote_start + 1;
let end = start + memchr::memchr(quote, &bytes[start..])?;
if bytes[name_start..name_end].eq_ignore_ascii_case(attribute.as_bytes()) {
return Some(&tag_xml[start..end]);
}
cursor = end + 1;
}
None
}
pub(crate) fn find_ignore_ascii_case(haystack: &str, start: usize, needle: &str) -> Option<usize> {
let source = haystack.as_bytes();
let needle = needle.as_bytes();
let anchor = needle.len() / 2;
let byte = *needle.get(anchor)?;
let limit = source.len().checked_sub(needle.len())? + 1;
if start >= limit {
return None;
}
let lower = byte.to_ascii_lowercase();
let upper = byte.to_ascii_uppercase();
let search_end = limit + anchor;
let mut cursor = start + anchor;
while cursor < search_end {
let offset = if lower == upper {
memchr::memchr(byte, &source[cursor..search_end])
} else {
memchr::memchr2(lower, upper, &source[cursor..search_end])
}?;
let position = cursor + offset - anchor;
if source[position..position + needle.len()].eq_ignore_ascii_case(needle) {
return Some(position);
}
cursor = position + anchor + 1;
}
None
}
pub fn parse_integer_attribute(tag_xml: &str, attribute: &str) -> Option<i64> {
let raw = get_attribute_ref(tag_xml, attribute)?;
if raw.is_empty() {
return None;
}
let parsed = raw.trim().parse::<f64>().ok()?;
if !parsed.is_finite() {
return None;
}
Some(parsed.round() as i64)
}
#[cfg(test)]
mod tests {
#[test]
fn direct_child_removal_preserves_nested_changes_and_borrows_unchanged_markup() {
let changed = "<w:pPr><w:spacing/><w:pPrChange><w:pPrChange/></w:pPrChange><w:pPrChange/><w:other><w:pPrChange/></w:other></w:pPr>";
let expected = "<w:pPr><w:spacing/><w:other><w:pPrChange/></w:other></w:pPr>";
let unchanged = "<w:pPr><!-- <w:pPrChange/> --><w:other><w:pPrChange/></w:other></w:pPr>";
for indexed in [false, true] {
let _scope = indexed.then(|| crate::xml_index::scope(
[("changed", changed), ("unchanged", unchanged)].into_iter(),
Default::default(),
));
assert_eq!(super::without_direct_child_tags(changed, "w:PPRCHANGE"), expected);
assert!(matches!(super::without_direct_child_tags(unchanged, "w:pPrChange"), std::borrow::Cow::Borrowed(_)));
assert_eq!(super::direct_child_tag(changed, "w:spacing"), Some("<w:spacing/>"));
}
}
use super::*;
#[test]
fn decode_xml_entities_replaces_entities_in_order() {
assert_eq!(decode_xml_entities("a&b"), "a&b");
assert_eq!(decode_xml_entities("<tag>"), "<tag>");
assert_eq!(decode_xml_entities(""x'"), "\"x'");
assert_eq!(decode_xml_entities("•"), "•");
assert_eq!(decode_xml_entities("•"), "•");
assert_eq!(decode_xml_entities("&#8226;"), "•");
assert_eq!(decode_xml_entities("¬-an-entity;"), "¬-an-entity;");
}
#[test]
fn extract_balanced_tag_ranges_matches_simple_pair() {
let xml = "<w:p><w:r>text</w:r></w:p>";
let ranges = extract_balanced_tag_ranges(xml, "w:p");
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, xml.len());
assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
}
#[test]
fn extract_balanced_tag_ranges_matches_self_closing_tag() {
let xml = "<w:br/>";
let ranges = extract_balanced_tag_ranges(xml, "w:br");
assert_eq!(
ranges,
vec![TagRange {
start: 0,
end: xml.len()
}]
);
}
#[test]
fn extract_balanced_tag_ranges_ignores_nested_same_tag() {
let xml = "<w:p><w:p>inner</w:p></w:p>";
let ranges = extract_balanced_tag_ranges(xml, "w:p");
assert_eq!(ranges.len(), 1);
assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
}
#[test]
fn extract_balanced_tag_ranges_ignores_nested_self_closing_tag() {
let xml = "<w:p><w:p/></w:p>";
let ranges = extract_balanced_tag_ranges(xml, "w:p");
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, xml.len());
}
#[test]
fn extract_balanced_tag_ranges_returns_multiple_top_level_ranges() {
let xml = "<w:p>a</w:p><w:p>b</w:p>";
let ranges = extract_balanced_tag_ranges(xml, "w:p");
assert_eq!(ranges.len(), 2);
assert_eq!(&xml[ranges[0].start..ranges[0].end], "<w:p>a</w:p>");
assert_eq!(&xml[ranges[1].start..ranges[1].end], "<w:p>b</w:p>");
}
#[test]
fn extract_balanced_tag_ranges_is_case_insensitive() {
let xml = "<W:P>text</W:P>";
let ranges = extract_balanced_tag_ranges(xml, "w:p");
assert_eq!(ranges.len(), 1);
assert_eq!(&xml[ranges[0].start..ranges[0].end], xml);
}
#[test]
fn extract_balanced_tag_blocks_returns_slices() {
let xml = "<w:p>one</w:p><w:p>two</w:p>";
let blocks = extract_balanced_tag_blocks(xml, "w:p");
assert_eq!(blocks, vec!["<w:p>one</w:p>", "<w:p>two</w:p>"]);
}
#[test]
fn extract_balanced_tag_blocks_in_order_filters_nested_ranges() {
let xml = "<w:tbl><w:tr><w:tc><w:p>cell</w:p></w:tc></w:tr></w:tbl>";
let ranges = extract_balanced_tag_blocks_in_order(xml, &["w:tbl", "w:tr", "w:tc", "w:p"]);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].tag_name, "w:tbl");
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].end, xml.len());
}
#[test]
fn extract_balanced_tag_blocks_in_order_sorts_by_start_and_end() {
let xml = "<w:p/><w:tbl><w:tr/></w:tbl>";
let ranges = extract_balanced_tag_blocks_in_order(xml, &["w:tbl", "w:p", "w:tr"]);
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].tag_name, "w:p");
assert_eq!(ranges[1].tag_name, "w:tbl");
}
#[test]
fn parse_on_off_attribute_defaults_to_true_without_val() {
assert_eq!(parse_on_off_attribute("<w:b/>", "b"), Some(true));
}
#[test]
fn parse_on_off_attribute_parses_false_values() {
assert_eq!(
parse_on_off_attribute(r#"<w:b w:val="false"/>"#, "b"),
Some(false)
);
assert_eq!(
parse_on_off_attribute(r#"<w:i w:val="0"/>"#, "i"),
Some(false)
);
}
#[test]
fn parse_underline_attribute_handles_color_only_tags() {
assert_eq!(
parse_underline_attribute(r#"<w:u w:color="FFFFFF"/>"#),
None
);
assert_eq!(parse_underline_attribute("<w:u/>"), Some(true));
assert_eq!(
parse_underline_attribute(r#"<w:u w:val="single"/>"#),
Some(true)
);
}
#[test]
fn normalize_alignment_maps_distribute_to_justify() {
assert_eq!(normalize_alignment(Some("distribute")), Some("justify"));
assert_eq!(normalize_alignment(Some("center")), Some("center"));
assert_eq!(normalize_alignment(Some("bogus")), None);
}
#[test]
fn get_attribute_reads_double_and_single_quoted_values() {
assert_eq!(
get_attribute(r#"foo="bar" baz='qux'"#, "foo"),
Some("bar".to_string())
);
assert_eq!(
get_attribute(r#"foo='bar'"#, "foo"),
Some("bar".to_string())
);
}
#[test]
fn parse_integer_attribute_rounds_numeric_values() {
assert_eq!(
parse_integer_attribute(r#"w:spacing w:val="3.6""#, "w:val"),
Some(4)
);
assert_eq!(
parse_integer_attribute(r#"w:spacing w:val="2""#, "w:val"),
Some(2)
);
assert_eq!(
parse_integer_attribute(r#"w:spacing w:val="abc""#, "w:val"),
None
);
}
}