use std::collections::HashMap;
use crate::model::{
ImageHorizontalAlign, ImageVerticalAlign, ParagraphAlignment, ParagraphBorderSet,
ParagraphBorderStyle, ParagraphIndent, ParagraphLineRule, ParagraphSpacing, ParagraphTabStop,
ParagraphTabStopAlignment, ParagraphTabStopLeader, TableBorderSet, TableBorderStyle,
TextRunBorderStyle, TextStyle,
};
use crate::package::OoxmlPart;
use crate::parse::context::{ContentTypeLookup, ThemeColorMap, ThemeFontMap};
use crate::parse::re;
use crate::xml::{
decode_xml_entities, extract_balanced_tag_blocks, get_attribute, normalize_alignment,
parse_integer_attribute,
};
#[cfg(target_arch = "wasm32")]
use base64::{engine::general_purpose::STANDARD, Engine as _};
pub fn regex_capture(xml: &str, pattern: &str) -> Option<String> {
if xml.is_empty() {
return None;
}
re::get(pattern)?
.captures(xml)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
}
pub fn regex_capture_tag(xml: &str, pattern: &str) -> Option<String> {
if xml.is_empty() {
return None;
}
re::get(pattern)?.find(xml).map(|m| m.as_str().to_string())
}
pub fn normalize_hex_color(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() || value.eq_ignore_ascii_case("auto") {
return None;
}
if re::get(r"^[0-9a-fA-F]{6}$")?.is_match(value) {
return Some(format!("#{value}"));
}
if re::get(r"^[0-9a-fA-F]{3}$")?.is_match(value) {
let expanded: String = value.chars().flat_map(|c| [c, c]).collect();
return Some(format!("#{expanded}"));
}
if re::get(r"^#[0-9a-fA-F]{6}$")?.is_match(value) {
return Some(value.to_string());
}
None
}
pub fn emu_to_pixels(value: Option<&str>) -> Option<f64> {
let value = value?;
if value.is_empty() {
return None;
}
let parsed: f64 = value.parse().ok()?;
if !parsed.is_finite() {
return None;
}
Some(((parsed / 9525.0) * 1000.0).round() / 1000.0)
}
pub fn to_model_alignment(raw: Option<&str>) -> Option<ParagraphAlignment> {
match normalize_alignment(raw)? {
"left" => Some(ParagraphAlignment::Left),
"center" => Some(ParagraphAlignment::Center),
"right" => Some(ParagraphAlignment::Right),
"justify" => Some(ParagraphAlignment::Justify),
_ => None,
}
}
pub fn normalize_heading_level(value: Option<&str>) -> Option<crate::model::HeadingLevel> {
let value = value?;
let caps = re::get(r"(?i)heading\s*([1-9])")?.captures(value)?;
let level: i64 = caps.get(1)?.as_str().parse().ok()?;
match level {
1 => Some(crate::model::HeadingLevel::One),
2 => Some(crate::model::HeadingLevel::Two),
3 => Some(crate::model::HeadingLevel::Three),
4 => Some(crate::model::HeadingLevel::Four),
5 => Some(crate::model::HeadingLevel::Five),
6 => Some(crate::model::HeadingLevel::Six),
7 => Some(crate::model::HeadingLevel::Seven),
8 => Some(crate::model::HeadingLevel::Eight),
9 => Some(crate::model::HeadingLevel::Nine),
_ => None,
}
}
pub fn prefer_alternate_content_choice(xml: &str) -> String {
active_xml(xml).into_owned()
}
pub(crate) fn active_xml(xml: &str) -> std::borrow::Cow<'_, str> {
static ALTERNATE_CONTENT: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"<mc:AlternateContent"));
if ALTERNATE_CONTENT.find(xml.as_bytes()).is_none() {
return std::borrow::Cow::Borrowed(xml);
}
if let Some(choices) = crate::xml_index::alternate_choices(xml) {
if choices.is_empty() {
return std::borrow::Cow::Borrowed(xml);
}
let mut output = String::with_capacity(xml.len());
let mut cursor = 0;
for (alternate, content) in choices {
output.push_str(&xml[cursor..alternate.start]);
output.push_str(&xml[content.start..content.end]);
cursor = alternate.end;
}
output.push_str(&xml[cursor..]);
return std::borrow::Cow::Owned(output);
}
if let Some(output) = balanced_active_xml(xml) {
return std::borrow::Cow::Owned(output);
}
if let Some(output) = scan_active_xml(xml) {
return output;
}
let re = re::get_unchecked(r"(?is)<mc:AlternateContent\b[\s\S]*?</mc:AlternateContent>");
re.replace_all(xml, |caps: ®ex::Captures| {
let alternate_xml = &caps[0];
let choice_re = re::get_unchecked(r"(?is)<mc:Choice\b[\s\S]*?</mc:Choice>");
let Some(choice_xml) = choice_re.find(alternate_xml) else {
return alternate_xml.to_string();
};
let choice = choice_xml.as_str();
let without_open = re::get_unchecked(r"(?i)<mc:Choice\b[^>]*>")
.replace(choice, "")
.to_string();
re::get_unchecked(r"(?i)</mc:Choice>")
.replace(&without_open, "")
.to_string()
})
}
fn balanced_active_xml(xml: &str) -> Option<String> {
let mut active = xml.to_string();
loop {
let ranges = crate::xml::extract_balanced_tag_ranges(&active, "mc:AlternateContent");
if ranges.is_empty() {
return Some(active);
}
let mut output = String::with_capacity(active.len());
let mut cursor = 0;
for range in ranges {
let alternate = &active[range.start..range.end];
let branch = crate::xml::direct_child_tag(alternate, "mc:Choice")
.or_else(|| crate::xml::direct_child_tag(alternate, "mc:Fallback"))?;
let begin = crate::xml::tag_token_end(branch, 0)?;
let end = branch.rfind("</")?;
if begin > end {
return None;
}
output.push_str(&active[cursor..range.start]);
output.push_str(&branch[begin..end]);
cursor = range.end;
}
output.push_str(&active[cursor..]);
if output.len() >= active.len() {
return None;
}
active = output;
}
}
fn scan_active_xml(xml: &str) -> Option<std::borrow::Cow<'_, str>> {
fn opening(xml: &str, mut start: usize, prefix: &str) -> Result<Option<usize>, ()> {
while let Some(position) = crate::xml::find_ignore_ascii_case(xml, start, prefix) {
let after = position + prefix.len();
match xml.as_bytes().get(after) {
Some(byte) if !byte.is_ascii() => return Err(()),
Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' => start = after,
_ => return Ok(Some(position)),
}
}
Ok(None)
}
const ALTERNATE_OPEN: &str = "<mc:AlternateContent";
const ALTERNATE_CLOSE: &str = "</mc:AlternateContent>";
const CHOICE_OPEN: &str = "<mc:Choice";
const CHOICE_CLOSE: &str = "</mc:Choice>";
let mut output: Option<String> = None;
let mut cursor = 0;
while let Some(start) = opening(xml, cursor, ALTERNATE_OPEN).ok()? {
let Some(close) = crate::xml::find_ignore_ascii_case(xml, start + ALTERNATE_OPEN.len(), ALTERNATE_CLOSE) else {
break;
};
let end = close + ALTERNATE_CLOSE.len();
let alternate = &xml[start..end];
let target = output.get_or_insert_with(|| String::with_capacity(xml.len()));
target.push_str(&xml[cursor..start]);
let choice = opening(alternate, 0, CHOICE_OPEN).ok()?.and_then(|start| {
let close = crate::xml::find_ignore_ascii_case(alternate, start + CHOICE_OPEN.len(), CHOICE_CLOSE)?;
let content = start + alternate[start..close + CHOICE_CLOSE.len()].find('>')? + 1;
Some((content, close))
});
if let Some((content, close)) = choice {
if content < close {
target.push_str(&alternate[content..close]);
}
} else {
target.push_str(alternate);
}
cursor = end;
}
Some(match output {
Some(mut output) => {
output.push_str(&xml[cursor..]);
std::borrow::Cow::Owned(output)
}
None => std::borrow::Cow::Borrowed(xml),
})
}
pub fn merge_text_styles(styles: &[Option<TextStyle>]) -> Option<TextStyle> {
merge_text_style_refs(&styles.iter().map(Option::as_ref).collect::<Vec<_>>())
}
pub(crate) fn merge_text_style_refs(styles: &[Option<&TextStyle>]) -> Option<TextStyle> {
let mut present = styles.iter().flatten();
let first = *present.next()?;
if present.next().is_none() {
return Some(first.clone());
}
let mut merged = TextStyle {
bold: None,
italic: None,
underline: None,
strike: None,
all_caps: None,
small_caps: None,
color: None,
highlight: None,
background_color: None,
font_size_pt: None,
font_size_cs_pt: None,
font_family: None,
source_font_family: None,
font_family_ascii: None,
font_family_h_ansi: None,
font_family_east_asia: None,
font_family_cs: None,
font_theme_ascii: None,
font_theme_h_ansi: None,
font_theme_east_asia: None,
font_theme_cs: None,
resolved_font_family_ascii: None,
resolved_font_family_h_ansi: None,
resolved_font_family_east_asia: None,
resolved_font_family_cs: None,
font_hint: None,
language: None,
language_east_asia: None,
language_bidi: None,
right_to_left: None,
complex_script: None,
character_spacing_twips: None,
character_scale_percent: None,
source_character_scale: None,
kerning_min_font_size_pt: None,
ligatures: None,
contextual_alternates: None,
vertical_align: None,
run_border: None,
};
let mut has_any = false;
for style in styles.iter().rev().flatten() {
has_any = true;
if merged.bold.is_none() {
merged.bold = style.bold;
}
if merged.italic.is_none() {
merged.italic = style.italic;
}
if merged.underline.is_none() {
merged.underline = style.underline;
}
if merged.strike.is_none() {
merged.strike = style.strike;
}
if merged.all_caps.is_none() {
merged.all_caps = style.all_caps;
}
if merged.small_caps.is_none() {
merged.small_caps = style.small_caps;
}
if merged.color.is_none() {
merged.color = style.color.clone();
}
if merged.highlight.is_none() {
merged.highlight = style.highlight.clone();
}
if merged.background_color.is_none() {
merged.background_color = style.background_color.clone();
}
if merged.font_size_pt.is_none() {
merged.font_size_pt = style.font_size_pt;
}
if merged.font_size_cs_pt.is_none() {
merged.font_size_cs_pt = style.font_size_cs_pt;
}
if merged.font_family.is_none() {
merged.font_family = style.font_family.clone();
}
if merged.source_font_family.is_none() {
merged.source_font_family = style.source_font_family.clone();
}
if merged.font_family_ascii.is_none() {
merged.font_family_ascii = style.font_family_ascii.clone();
}
if merged.font_family_h_ansi.is_none() {
merged.font_family_h_ansi = style.font_family_h_ansi.clone();
}
if merged.font_family_east_asia.is_none() {
merged.font_family_east_asia = style.font_family_east_asia.clone();
}
if merged.font_family_cs.is_none() {
merged.font_family_cs = style.font_family_cs.clone();
}
if merged.font_theme_ascii.is_none() {
merged.font_theme_ascii = style.font_theme_ascii.clone();
}
if merged.font_theme_h_ansi.is_none() {
merged.font_theme_h_ansi = style.font_theme_h_ansi.clone();
}
if merged.font_theme_east_asia.is_none() {
merged.font_theme_east_asia = style.font_theme_east_asia.clone();
}
if merged.font_theme_cs.is_none() {
merged.font_theme_cs = style.font_theme_cs.clone();
}
if merged.resolved_font_family_ascii.is_none() {
merged.resolved_font_family_ascii = style.resolved_font_family_ascii.clone();
}
if merged.resolved_font_family_h_ansi.is_none() {
merged.resolved_font_family_h_ansi = style.resolved_font_family_h_ansi.clone();
}
if merged.resolved_font_family_east_asia.is_none() {
merged.resolved_font_family_east_asia = style.resolved_font_family_east_asia.clone();
}
if merged.resolved_font_family_cs.is_none() {
merged.resolved_font_family_cs = style.resolved_font_family_cs.clone();
}
if merged.font_hint.is_none() {
merged.font_hint = style.font_hint.clone();
}
if merged.language.is_none() {
merged.language = style.language.clone();
}
if merged.language_east_asia.is_none() {
merged.language_east_asia = style.language_east_asia.clone();
}
if merged.language_bidi.is_none() {
merged.language_bidi = style.language_bidi.clone();
}
if merged.right_to_left.is_none() {
merged.right_to_left = style.right_to_left;
}
if merged.complex_script.is_none() {
merged.complex_script = style.complex_script;
}
if merged.character_scale_percent.is_none() && merged.source_character_scale.is_none() {
merged.character_scale_percent = style.character_scale_percent;
merged.source_character_scale = style.source_character_scale.clone();
}
if merged.character_spacing_twips.is_none() {
merged.character_spacing_twips = style.character_spacing_twips;
}
if merged.kerning_min_font_size_pt.is_none() {
merged.kerning_min_font_size_pt = style.kerning_min_font_size_pt;
}
if merged.ligatures.is_none() {
merged.ligatures = style.ligatures;
}
if merged.contextual_alternates.is_none() {
merged.contextual_alternates = style.contextual_alternates;
}
if merged.vertical_align.is_none() {
merged.vertical_align = style.vertical_align;
}
if merged.run_border.is_none() {
merged.run_border = style.run_border.clone();
}
}
if has_any {
Some(merged)
} else {
None
}
}
pub fn merge_paragraph_spacing(
inherited: Option<&ParagraphSpacing>,
direct: Option<&ParagraphSpacing>,
) -> Option<ParagraphSpacing> {
if inherited.is_none() && direct.is_none() {
return None;
}
let merged = ParagraphSpacing {
before_twips: direct
.and_then(|d| d.before_twips)
.or(inherited.and_then(|i| i.before_twips)),
after_twips: direct
.and_then(|d| d.after_twips)
.or(inherited.and_then(|i| i.after_twips)),
before_auto_spacing: direct
.and_then(|d| d.before_auto_spacing)
.or(inherited.and_then(|i| i.before_auto_spacing)),
after_auto_spacing: direct
.and_then(|d| d.after_auto_spacing)
.or(inherited.and_then(|i| i.after_auto_spacing)),
line_twips: direct
.and_then(|d| d.line_twips)
.or(inherited.and_then(|i| i.line_twips)),
line_rule: direct
.and_then(|d| d.line_rule)
.or(inherited.and_then(|i| i.line_rule)),
};
if merged.before_twips.is_none()
&& merged.after_twips.is_none()
&& merged.before_auto_spacing.is_none()
&& merged.after_auto_spacing.is_none()
&& merged.line_twips.is_none()
&& merged.line_rule.is_none()
{
return None;
}
Some(merged)
}
pub fn merge_paragraph_indent(
inherited: Option<&ParagraphIndent>,
direct: Option<&ParagraphIndent>,
) -> Option<ParagraphIndent> {
if inherited.is_none() && direct.is_none() {
return None;
}
let merged = ParagraphIndent {
left_twips: direct
.and_then(|d| d.left_twips)
.or(inherited.and_then(|i| i.left_twips)),
right_twips: direct
.and_then(|d| d.right_twips)
.or(inherited.and_then(|i| i.right_twips)),
first_line_twips: direct
.and_then(|d| d.first_line_twips)
.or(inherited.and_then(|i| i.first_line_twips)),
hanging_twips: direct
.and_then(|d| d.hanging_twips)
.or(inherited.and_then(|i| i.hanging_twips)),
};
if merged.left_twips.is_none()
&& merged.right_twips.is_none()
&& merged.first_line_twips.is_none()
&& merged.hanging_twips.is_none()
{
return None;
}
Some(merged)
}
pub fn merge_paragraph_boolean(inherited: Option<bool>, direct: Option<bool>) -> Option<bool> {
direct.or(inherited)
}
pub fn merge_paragraph_border_style(
inherited: Option<&ParagraphBorderStyle>,
direct: Option<&ParagraphBorderStyle>,
) -> Option<ParagraphBorderStyle> {
if inherited.is_none() && direct.is_none() {
return None;
}
let border_type = direct
.map(|d| d.border_type.clone())
.or_else(|| inherited.map(|i| i.border_type.clone()))?;
Some(ParagraphBorderStyle {
border_type,
color: direct
.and_then(|d| d.color.clone())
.or_else(|| inherited.and_then(|i| i.color.clone())),
size_eighth_pt: direct
.and_then(|d| d.size_eighth_pt)
.or(inherited.and_then(|i| i.size_eighth_pt)),
space_pt: direct
.and_then(|d| d.space_pt)
.or(inherited.and_then(|i| i.space_pt)),
frame: direct
.and_then(|d| d.frame)
.or(inherited.and_then(|i| i.frame)),
shadow: direct
.and_then(|d| d.shadow)
.or(inherited.and_then(|i| i.shadow)),
})
}
pub fn merge_paragraph_border_sets(
inherited: Option<&ParagraphBorderSet>,
direct: Option<&ParagraphBorderSet>,
) -> Option<ParagraphBorderSet> {
if inherited.is_none() && direct.is_none() {
return None;
}
let merged = ParagraphBorderSet {
top: merge_paragraph_border_style(
inherited.and_then(|i| i.top.as_ref()),
direct.and_then(|d| d.top.as_ref()),
),
right: merge_paragraph_border_style(
inherited.and_then(|i| i.right.as_ref()),
direct.and_then(|d| d.right.as_ref()),
),
bottom: merge_paragraph_border_style(
inherited.and_then(|i| i.bottom.as_ref()),
direct.and_then(|d| d.bottom.as_ref()),
),
left: merge_paragraph_border_style(
inherited.and_then(|i| i.left.as_ref()),
direct.and_then(|d| d.left.as_ref()),
),
between: merge_paragraph_border_style(
inherited.and_then(|i| i.between.as_ref()),
direct.and_then(|d| d.between.as_ref()),
),
bar: merge_paragraph_border_style(
inherited.and_then(|i| i.bar.as_ref()),
direct.and_then(|d| d.bar.as_ref()),
),
};
if merged.top.is_none()
&& merged.right.is_none()
&& merged.bottom.is_none()
&& merged.left.is_none()
&& merged.between.is_none()
&& merged.bar.is_none()
{
return None;
}
Some(merged)
}
pub fn merge_paragraph_tab_stops(
inherited: Option<&[ParagraphTabStop]>,
direct: Option<&[ParagraphTabStop]>,
) -> Option<Vec<ParagraphTabStop>> {
if inherited.is_none() && direct.is_none() {
return None;
}
let mut by_position: HashMap<i64, ParagraphTabStop> = HashMap::new();
for stop in inherited.unwrap_or(&[]) {
if let Some(pos) = stop.position_twips {
by_position.insert(pos, stop.clone());
}
}
for stop in direct.unwrap_or(&[]) {
if let Some(pos) = stop.position_twips {
by_position.insert(pos, stop.clone());
}
}
if by_position.is_empty() {
return None;
}
let mut combined: Vec<_> = by_position.into_values().collect();
combined.sort_by_key(|s| s.position_twips.unwrap_or(0));
Some(combined)
}
pub fn merge_table_border_sets(
inherited: Option<&TableBorderSet>,
direct: Option<&TableBorderSet>,
) -> Option<TableBorderSet> {
if inherited.is_none() && direct.is_none() {
return None;
}
let merged = TableBorderSet {
top: direct
.and_then(|d| d.top.clone())
.or_else(|| inherited.and_then(|i| i.top.clone())),
right: direct
.and_then(|d| d.right.clone())
.or_else(|| inherited.and_then(|i| i.right.clone())),
bottom: direct
.and_then(|d| d.bottom.clone())
.or_else(|| inherited.and_then(|i| i.bottom.clone())),
left: direct
.and_then(|d| d.left.clone())
.or_else(|| inherited.and_then(|i| i.left.clone())),
inside_h: direct
.and_then(|d| d.inside_h.clone())
.or_else(|| inherited.and_then(|i| i.inside_h.clone())),
inside_v: direct
.and_then(|d| d.inside_v.clone())
.or_else(|| inherited.and_then(|i| i.inside_v.clone())),
tl2br: direct
.and_then(|d| d.tl2br.clone())
.or_else(|| inherited.and_then(|i| i.tl2br.clone())),
tr2bl: direct
.and_then(|d| d.tr2bl.clone())
.or_else(|| inherited.and_then(|i| i.tr2bl.clone())),
};
if merged.top.is_none()
&& merged.right.is_none()
&& merged.bottom.is_none()
&& merged.left.is_none()
&& merged.inside_h.is_none()
&& merged.inside_v.is_none()
&& merged.tl2br.is_none()
&& merged.tr2bl.is_none()
{
return None;
}
Some(merged)
}
pub fn on_off_value_to_boolean(value: Option<&str>) -> Option<bool> {
let value = value?.trim();
if value.is_empty() {
return None;
}
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Some(true),
"0" | "false" | "off" => Some(false),
_ => None,
}
}
pub fn decode_xml_attribute(value: Option<&str>) -> Option<String> {
value.map(decode_xml_entities)
}
pub fn decode_hex_code_point(value: Option<&str>) -> Option<String> {
let value = value?
.trim()
.trim_start_matches("0x")
.trim_start_matches("0X");
if value.is_empty() || !value.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let code_point = i32::from_str_radix(value, 16).ok()?;
if code_point <= 0 {
return None;
}
char::from_u32(code_point as u32).map(|c| c.to_string())
}
pub fn parse_on_off_tag_value(tag_xml: Option<&str>, attributes: &[&str]) -> Option<bool> {
let tag_xml = tag_xml?;
for attribute in attributes {
if let Some(raw) = get_attribute(tag_xml, attribute) {
return on_off_value_to_boolean(Some(&raw));
}
}
Some(true)
}
pub fn clamp(value: f64, minimum: f64, maximum: f64) -> f64 {
value.max(minimum).min(maximum)
}
pub fn clamp_i64(value: i64, minimum: i64, maximum: i64) -> i64 {
value.max(minimum).min(maximum)
}
pub fn escape_xml_text(value: &str) -> String {
let bytes = value.as_bytes();
if memchr::memchr3(b'&', b'<', b'>', bytes).is_none()
&& memchr::memchr2(b'"', b'\'', bytes).is_none()
{
return value.to_owned();
}
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub fn svg_data_uri(svg: &str) -> String {
const PREFIX: &[u8] = b"data:image/svg+xml;charset=utf-8,";
const HEX: &[u8; 16] = b"0123456789ABCDEF";
const UNRESERVED: [bool; 256] = {
let mut values = [false; 256];
let mut byte = 0;
while byte < values.len() {
values[byte] = matches!(byte as u8,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~');
byte += 1;
}
values
};
let capacity = svg.len().checked_add(svg.len() / 8)
.and_then(|length| length.checked_add(PREFIX.len()))
.expect("encoded data exceeds addressable size");
let mut output = Vec::with_capacity(capacity);
output.extend_from_slice(PREFIX);
let bytes = svg.as_bytes();
let mut start = 0;
for (at, &byte) in bytes.iter().enumerate() {
if UNRESERVED[byte as usize] {
continue;
}
output.extend_from_slice(&bytes[start..at]);
output.extend_from_slice(&[b'%', HEX[(byte >> 4) as usize], HEX[(byte & 15) as usize]]);
start = at + 1;
}
output.extend_from_slice(&bytes[start..]);
String::from_utf8(output).expect("percent-encoded output is ASCII")
}
pub fn bytes_to_base64(bytes: &[u8]) -> String {
#[cfg(not(target_arch = "wasm32"))]
{ base64_simd::STANDARD.encode_to_string(bytes) }
#[cfg(target_arch = "wasm32")]
{ STANDARD.encode(bytes) }
}
pub(crate) fn append_base64(bytes: &[u8], output: &mut String) {
#[cfg(not(target_arch = "wasm32"))]
base64_simd::STANDARD.encode_append(bytes, output);
#[cfg(target_arch = "wasm32")]
STANDARD.encode_string(bytes, output);
}
pub(crate) fn base64_data_uri(content_type: &str, bytes: &[u8]) -> String {
let encoded = base64::encoded_len(bytes.len(), true).expect("encoded data exceeds addressable size");
let mut output = String::with_capacity(13 + content_type.len() + encoded);
output.push_str("data:");
output.push_str(content_type);
output.push_str(";base64,");
append_base64(bytes, &mut output);
output
}
pub fn extension_from_part_name(part_name: &str) -> Option<String> {
let last_dot = part_name.rfind('.')?;
if last_dot == part_name.len() - 1 {
return None;
}
Some(part_name[last_dot + 1..].to_ascii_lowercase())
}
pub fn content_type_for_part(part_name: &str, content_types: &ContentTypeLookup) -> Option<String> {
if let Some(override_type) = content_types
.override_by_part_name
.get(part_name)
.or_else(|| {
content_types
.override_by_part_name
.get(&format!("/{part_name}"))
})
{
return Some(override_type.clone());
}
let extension = extension_from_part_name(part_name)?;
let mime_by_extension: HashMap<&str, &str> = [
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("bmp", "image/bmp"),
("wmf", "image/wmf"),
("emf", "image/emf"),
("webp", "image/webp"),
("svg", "image/svg+xml"),
]
.into_iter()
.collect();
content_types
.default_by_extension
.get(&extension)
.cloned()
.or_else(|| {
mime_by_extension
.get(extension.as_str())
.map(|s| (*s).to_string())
})
}
pub fn resolve_part_path(base_part_name: &str, target: &str) -> String {
if target.is_empty() {
return String::new();
}
if re::get_unchecked(r"(?i)^[a-z][a-z0-9+.-]*:").is_match(target) || target.starts_with('#') {
return target.to_string();
}
if target.starts_with('/') {
return target[1..].to_string();
}
let mut output: Vec<&str> = base_part_name.split('/').collect();
output.pop();
for segment in target.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
output.pop();
continue;
}
output.push(segment);
}
output.join("/")
}
pub fn relationship_part_name_for_part(part_name: &str) -> String {
let mut segments: Vec<&str> = part_name.split('/').collect();
let file_name = segments.pop().unwrap_or("");
let folder = segments.join("/");
if folder.is_empty() {
format!("_rels/{file_name}.rels")
} else {
format!("{folder}/_rels/{file_name}.rels")
}
}
pub fn parse_relationships_from_parts(
parts: &HashMap<String, OoxmlPart>,
part_name: &str,
) -> HashMap<String, String> {
let mut map = HashMap::new();
let relationships_part_name = relationship_part_name_for_part(part_name);
let Some(relationships_part) = parts.get(&relationships_part_name) else {
return map;
};
let re = re::get_unchecked(r"(?i)<Relationship\b[^>]*>");
for mat in re.find_iter(&relationships_part.content) {
let tag = mat.as_str();
let Some(id) = get_attribute(tag, "Id") else {
continue;
};
let Some(target) = get_attribute(tag, "Target") else {
continue;
};
map.insert(id, resolve_part_path(part_name, &target));
}
map
}
pub fn is_windows_metafile_content_type(
content_type: Option<&str>,
part_name: Option<&str>,
) -> bool {
if let Some(normalized) = content_type.map(|value| value.trim().to_ascii_lowercase()) {
if matches!(
normalized.as_str(),
"image/wmf"
| "image/x-wmf"
| "application/x-wmf"
| "image/emf"
| "image/x-emf"
| "application/x-emf"
) {
return true;
}
}
matches!(
part_name.and_then(extension_from_part_name).as_deref(),
Some("wmf" | "emf")
)
}
pub fn rasterize_windows_metafile_to_png_data_uri(
_bytes: &[u8],
_part_name: Option<&str>,
) -> Option<String> {
None
}
pub fn windows_metafile_to_svg_data_uri(bytes: &[u8]) -> Option<String> {
crate::emf::emf_to_svg(bytes).map(|svg| svg_data_uri(&svg))
}
pub fn resolve_theme_font(theme_token: Option<&str>, theme_fonts: &ThemeFontMap) -> Option<String> {
let theme_token = theme_token?;
let normalized = theme_token.to_ascii_lowercase();
if normalized.starts_with("major") {
if normalized.contains("eastasia") {
return theme_fonts
.major_east_asia
.clone()
.or_else(|| theme_fonts.major_latin.clone());
}
if normalized.contains("bidi") || normalized.contains("cs") {
return theme_fonts
.major_complex_script
.clone()
.or_else(|| theme_fonts.major_latin.clone());
}
return theme_fonts.major_latin.clone();
}
if normalized.starts_with("minor") {
if normalized.contains("eastasia") {
return theme_fonts
.minor_east_asia
.clone()
.or_else(|| theme_fonts.minor_latin.clone());
}
if normalized.contains("bidi") || normalized.contains("cs") {
return theme_fonts
.minor_complex_script
.clone()
.or_else(|| theme_fonts.minor_latin.clone());
}
return theme_fonts.minor_latin.clone();
}
None
}
pub fn resolve_drawing_color_from_xml(
color_xml: Option<&str>,
theme_colors: &ThemeColorMap,
) -> Option<(String, Option<f64>)> {
super::colors::resolve_drawing_color_from_xml(color_xml, theme_colors)
.map(|resolved| (resolved.color, resolved.opacity))
}
pub fn parse_paragraph_spacing_from_xml(xml: &str) -> Option<ParagraphSpacing> {
if xml.is_empty() {
return None;
}
let spacing_tag = super::scan::find_tag_token_ref(xml, "w:spacing")?;
let line_rule_raw = get_attribute(&spacing_tag, "w:lineRule").map(|v| v.to_ascii_lowercase());
let line_rule = match line_rule_raw.as_deref() {
Some("auto") => Some(ParagraphLineRule::Auto),
Some("exact") => Some(ParagraphLineRule::Exact),
Some("atleast") => Some(ParagraphLineRule::AtLeast),
_ => None,
};
let spacing = ParagraphSpacing {
before_twips: parse_integer_attribute(&spacing_tag, "w:before"),
after_twips: parse_integer_attribute(&spacing_tag, "w:after"),
before_auto_spacing: super::styles::parse_on_off_value(
get_attribute(&spacing_tag, "w:beforeAutospacing").as_deref(),
),
after_auto_spacing: super::styles::parse_on_off_value(
get_attribute(&spacing_tag, "w:afterAutospacing").as_deref(),
),
line_twips: parse_integer_attribute(&spacing_tag, "w:line"),
line_rule,
};
if spacing.before_twips.is_none()
&& spacing.after_twips.is_none()
&& spacing.before_auto_spacing.is_none()
&& spacing.after_auto_spacing.is_none()
&& spacing.line_twips.is_none()
&& spacing.line_rule.is_none()
{
return None;
}
Some(spacing)
}
pub fn parse_paragraph_indent_from_xml(xml: &str) -> Option<ParagraphIndent> {
if xml.is_empty() {
return None;
}
let indent_tag = super::scan::find_tag_token_ref(xml, "w:ind")?;
let indent = ParagraphIndent {
left_twips: parse_integer_attribute(&indent_tag, "w:left"),
right_twips: parse_integer_attribute(&indent_tag, "w:right"),
first_line_twips: parse_integer_attribute(&indent_tag, "w:firstLine"),
hanging_twips: parse_integer_attribute(&indent_tag, "w:hanging"),
};
if indent.left_twips.is_none()
&& indent.right_twips.is_none()
&& indent.first_line_twips.is_none()
&& indent.hanging_twips.is_none()
{
return None;
}
Some(indent)
}
pub fn parse_paragraph_shading_from_xml(xml: &str) -> Option<String> {
if xml.is_empty() {
return None;
}
let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd")?;
normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
}
pub fn parse_text_run_border_style(tag_xml: Option<&str>) -> Option<TextRunBorderStyle> {
let tag_xml = tag_xml?;
let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
if border_type.is_empty() {
return None;
}
let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
let raw_color = get_attribute(tag_xml, "w:color");
let color = if raw_color
.as_deref()
.map(|c| c.eq_ignore_ascii_case("auto"))
.unwrap_or(false)
{
None
} else {
normalize_hex_color(raw_color.as_deref())
};
let frame = get_attribute(tag_xml, "w:frame")
.as_deref()
.and_then(|value| on_off_value_to_boolean(Some(value)));
let shadow = get_attribute(tag_xml, "w:shadow")
.as_deref()
.and_then(|value| on_off_value_to_boolean(Some(value)));
Some(TextRunBorderStyle {
border_type,
color,
size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
space_pt: space_pt.filter(|v| *v >= 0.0),
frame,
shadow,
})
}
pub fn parse_paragraph_border_style(tag_xml: Option<&str>) -> Option<ParagraphBorderStyle> {
let tag_xml = tag_xml?;
let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
if border_type.is_empty() {
return None;
}
let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
let raw_color = get_attribute(tag_xml, "w:color");
let color = if raw_color
.as_deref()
.map(|c| c.eq_ignore_ascii_case("auto"))
.unwrap_or(false)
{
Some("#000000".to_string())
} else {
normalize_hex_color(raw_color.as_deref())
};
let frame = get_attribute(tag_xml, "w:frame")
.as_deref()
.and_then(|value| on_off_value_to_boolean(Some(value)));
let shadow = get_attribute(tag_xml, "w:shadow")
.as_deref()
.and_then(|value| on_off_value_to_boolean(Some(value)));
Some(ParagraphBorderStyle {
border_type,
color,
size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
space_pt: space_pt.filter(|v| *v >= 0.0),
frame,
shadow,
})
}
pub fn parse_paragraph_border_set_from_xml(xml: &str) -> Option<ParagraphBorderSet> {
if xml.is_empty() {
return None;
}
let paragraph_border_xml = extract_balanced_tag_blocks(xml, "w:pBdr")
.into_iter()
.next()
.or_else(|| super::scan::find_tag_token_ref(xml, "w:pBdr").map(str::to_owned))?;
if paragraph_border_xml.is_empty() {
return None;
}
let top = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:top").as_deref(),
);
let right = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:right").as_deref(),
);
let bottom = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:bottom").as_deref(),
);
let left = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:left").as_deref(),
);
let between = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:between").as_deref(),
);
let bar = parse_paragraph_border_style(
super::scan::find_tag_token_ref(¶graph_border_xml, "w:bar").as_deref(),
);
if top.is_none()
&& right.is_none()
&& bottom.is_none()
&& left.is_none()
&& between.is_none()
&& bar.is_none()
{
return None;
}
Some(ParagraphBorderSet {
top,
right,
bottom,
left,
between,
bar,
})
}
pub fn parse_table_border_style(tag_xml: Option<&str>) -> Option<TableBorderStyle> {
let tag_xml = tag_xml?;
let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
if border_type.is_empty() {
return None;
}
let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
let raw_color = get_attribute(tag_xml, "w:color");
let color = if raw_color
.as_deref()
.map(|c| c.eq_ignore_ascii_case("auto"))
.unwrap_or(false)
{
Some("#000000".to_string())
} else {
normalize_hex_color(raw_color.as_deref())
};
Some(TableBorderStyle {
border_type,
color,
size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
})
}
pub fn parse_table_border_set(xml: &str) -> Option<TableBorderSet> {
let top = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:top").as_deref());
let right =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:right").as_deref());
let bottom =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:bottom").as_deref());
let left = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:left").as_deref());
let inside_h =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideH").as_deref());
let inside_v =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideV").as_deref());
let tl2br =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tl2br").as_deref());
let tr2bl =
parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tr2bl").as_deref());
if top.is_none()
&& right.is_none()
&& bottom.is_none()
&& left.is_none()
&& inside_h.is_none()
&& inside_v.is_none()
&& tl2br.is_none()
&& tr2bl.is_none()
{
return None;
}
Some(TableBorderSet {
top,
right,
bottom,
left,
inside_h,
inside_v,
tl2br,
tr2bl,
})
}
pub fn normalize_tab_stop_alignment(value: Option<&str>) -> Option<ParagraphTabStopAlignment> {
match value?.to_ascii_lowercase().as_str() {
"clear" => Some(ParagraphTabStopAlignment::Clear),
"left" => Some(ParagraphTabStopAlignment::Left),
"center" => Some(ParagraphTabStopAlignment::Center),
"right" => Some(ParagraphTabStopAlignment::Right),
"decimal" => Some(ParagraphTabStopAlignment::Decimal),
"bar" => Some(ParagraphTabStopAlignment::Bar),
_ => None,
}
}
pub fn normalize_tab_stop_leader(value: Option<&str>) -> Option<ParagraphTabStopLeader> {
match value? {
"none" => Some(ParagraphTabStopLeader::LeaderNone),
"dot" => Some(ParagraphTabStopLeader::Dot),
"hyphen" => Some(ParagraphTabStopLeader::Hyphen),
"underscore" => Some(ParagraphTabStopLeader::Underscore),
"middleDot" => Some(ParagraphTabStopLeader::MiddleDot),
_ => None,
}
}
pub fn parse_paragraph_tab_stops_from_xml(xml: &str) -> Vec<ParagraphTabStop> {
if xml.is_empty() {
return Vec::new();
}
let tabs_tag = regex_capture_tag(
xml,
r"(?is)<w:tabs\b[^>]*>[\s\S]*?</w:tabs>|<w:tabs\b[^>]*/>",
)
.unwrap_or_default();
if tabs_tag.is_empty() {
return Vec::new();
}
let re = re::get_unchecked(r"(?i)<w:tab\b[^>]*/>");
let mut tab_stops = Vec::new();
for mat in re.find_iter(&tabs_tag) {
let tab_tag = mat.as_str();
let Some(position_twips) = parse_integer_attribute(tab_tag, "w:pos") else {
continue;
};
tab_stops.push(ParagraphTabStop {
alignment: normalize_tab_stop_alignment(get_attribute(tab_tag, "w:val").as_deref())
.or(Some(ParagraphTabStopAlignment::Left)),
leader: normalize_tab_stop_leader(get_attribute(tab_tag, "w:leader").as_deref())
.or(Some(ParagraphTabStopLeader::LeaderNone)),
position_twips: Some(position_twips),
});
}
tab_stops.sort_by_key(|s| s.position_twips.unwrap_or(0));
tab_stops
}
pub fn parse_paragraph_numbering_parts_from_xml(xml: &str) -> Option<(Option<i64>, Option<i64>)> {
if xml.is_empty() {
return None;
}
let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
.into_iter()
.next()
.or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
if numbering_xml.is_empty() {
return None;
}
let num_id = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)
.and_then(|raw| raw.parse::<i64>().ok());
let ilvl = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#)
.and_then(|raw| raw.parse::<i64>().ok());
Some((num_id, ilvl))
}
pub fn parse_paragraph_numbering_from_xml(xml: &str) -> Option<crate::model::ParagraphNumbering> {
if xml.is_empty() {
return None;
}
let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
.into_iter()
.next()
.or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
if numbering_xml.is_empty() {
return None;
}
let num_id_raw = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)?;
let num_id: i64 = num_id_raw.parse().ok()?;
if num_id <= 0 {
return None;
}
let ilvl_raw = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#);
let ilvl_value: i64 = ilvl_raw.and_then(|v| v.parse().ok()).unwrap_or(0);
Some(crate::model::ParagraphNumbering {
num_id,
ilvl: ilvl_value.max(0),
})
}
pub fn to_image_horizontal_align(raw: Option<&str>) -> Option<ImageHorizontalAlign> {
match raw? {
"left" => Some(ImageHorizontalAlign::Left),
"center" => Some(ImageHorizontalAlign::Center),
"right" => Some(ImageHorizontalAlign::Right),
"inside" => Some(ImageHorizontalAlign::Inside),
"outside" => Some(ImageHorizontalAlign::Outside),
_ => None,
}
}
pub fn to_image_vertical_align(raw: Option<&str>) -> Option<ImageVerticalAlign> {
match raw? {
"top" => Some(ImageVerticalAlign::Top),
"center" => Some(ImageVerticalAlign::Center),
"bottom" => Some(ImageVerticalAlign::Bottom),
"inside" => Some(ImageVerticalAlign::Inside),
"outside" => Some(ImageVerticalAlign::Outside),
_ => None,
}
}
pub fn strip_text_box_content(xml: &str) -> String {
let re = re::get_unchecked(r"(?is)<w:txbxContent\b[\s\S]*?</w:txbxContent>");
re.replace_all(xml, "").to_string()
}
pub fn normalize_legacy_form_display_value(value: Option<&str>) -> Option<String> {
let value = value?;
let normalized = value
.replace('\u{2002}', " ")
.replace('\u{2003}', " ")
.replace('\u{00a0}', " ");
if normalized.trim().is_empty() {
None
} else {
Some(normalized)
}
}
#[cfg(test)]
mod tests {
use super::on_off_value_to_boolean;
#[test]
fn parses_only_valid_st_on_off_lexical_values() {
for value in ["1", "true", "TRUE", "on", "ON"] {
assert_eq!(on_off_value_to_boolean(Some(value)), Some(true));
}
for value in ["0", "false", "FALSE", "off", "OFF"] {
assert_eq!(on_off_value_to_boolean(Some(value)), Some(false));
}
for value in ["", "yes", "no", "maybe"] {
assert_eq!(on_off_value_to_boolean(Some(value)), None);
}
}
}
#[cfg(test)]
mod base64_output_tests {
use super::*;
use base64::Engine as _;
#[test]
fn encoding_preserves_all_tails_and_prefixed_output() {
let mut state = 0x61c4_28d9_u64;
let mut bytes = Vec::new();
for _ in 0..=1048576 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
bytes.push((state >> 32) as u8);
}
for length in (0..=512).chain([1023, 1024, 4097, 65539, 1048577]) {
let data = &bytes[..length];
let expected = base64::engine::general_purpose::STANDARD.encode(data);
assert_eq!(bytes_to_base64(data), expected);
for prefix in ["", "prefix:", "π:payload,"] {
let mut output = prefix.to_owned();
append_base64(data, &mut output);
assert_eq!(output, format!("{prefix}{expected}"));
}
assert_eq!(base64_data_uri("application/octet-stream", data), format!("data:application/octet-stream;base64,{expected}"));
}
}
}
#[cfg(test)]
mod svg_uri_encoding_tests {
#[test]
fn reserved_characters_and_unicode_use_uppercase_utf8_escapes() {
assert_eq!(super::svg_data_uri("<svg a=\"a b&é😀\"/>"), "data:image/svg+xml;charset=utf-8,%3Csvg%20a%3D%22a%20b%26%C3%A9%F0%9F%98%80%22%2F%3E");
assert_eq!(super::svg_data_uri("AZaz09-_.~"), "data:image/svg+xml;charset=utf-8,AZaz09-_.~");
assert_eq!(super::svg_data_uri(""), "data:image/svg+xml;charset=utf-8,");
assert_eq!(super::svg_data_uri("\0\t\r\n%+/"), "data:image/svg+xml;charset=utf-8,%00%09%0D%0A%25%2B%2F");
}
}
#[cfg(test)]
mod alternate_scanner_tests {
fn reference(xml: &str) -> String {
use super::re;
let re = re::get_unchecked(r"(?is)<mc:AlternateContent\b[\s\S]*?</mc:AlternateContent>");
re.replace_all(xml, |caps: ®ex::Captures| {
let alternate = &caps[0];
let Some(choice) = re::get_unchecked(r"(?is)<mc:Choice\b[\s\S]*?</mc:Choice>").find(alternate) else {
return alternate.to_owned();
};
let content = re::get_unchecked(r"(?i)<mc:Choice\b[^>]*>").replace(choice.as_str(), "");
re::get_unchecked(r"(?i)</mc:Choice>").replace(&content, "").into_owned()
}).into_owned()
}
#[test]
fn scanner_preserves_lazy_matching_and_boundary_behavior() {
let opens = ["<mc:AlternateContent>", "<MC:ALTERNATECONTENT>", "<mc:AlternateContent:extra>", "<mc:AlternateContent_>", "<mc:AlternateContenté>", "<mc:AlternateContent\u{301}>", "<mc:AlternateContent"];
let contents = ["<mc:Choice>one</mc:Choice><mc:Fallback>two</mc:Fallback>", "<MC:CHOICE a=\"x>y\">one</MC:CHOICE>", "<mc:Choice:extra>one</mc:Choice>", "<mc:Choice_>one</mc:Choice>", "<mc:Choiceé>one</mc:Choice>", "<mc:Choice\u{301}>one</mc:Choice>", "<mc:Choice</mc:Choice>", "<mc:Choice>outer<mc:Choice>inner</mc:Choice>tail</mc:Choice>", "<mc:Fallback>retained</mc:Fallback>", "<mc:AlternateContent><mc:Choice>nested</mc:Choice></mc:AlternateContent>"];
for open in opens {
for content in contents {
for close in ["</mc:AlternateContent>", "</MC:ALTERNATECONTENT>", ""] {
let xml = format!("before{open}{content}{close}after<mc:AlternateContent><mc:Choice>last</mc:Choice></mc:AlternateContent>");
if let Some(scanned) = super::scan_active_xml(&xml) {
assert_eq!(scanned, reference(&xml));
}
}
}
}
assert!(super::scan_active_xml("<mc:AlternateContent><mc:Choice>one</mc:Choice></mc:AlternateContent>").is_some());
}
}
#[cfg(test)]
mod sparse_encoding_tests {
#[test]
fn all_scalar_values_survive_encoded_and_unchanged_boundaries() {
use std::fmt::Write;
for scalar in (0..=0x10ffff).filter_map(char::from_u32) {
let text = format!("a{scalar}%{scalar}z");
let mut expected = String::from("data:image/svg+xml;charset=utf-8,");
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
expected.push(byte as char);
} else {
write!(expected, "%{byte:02X}").unwrap();
}
}
assert_eq!(super::svg_data_uri(&text), expected);
}
}
#[test]
fn long_runs_and_xml_escaping_preserve_exact_bytes() {
let run = "AZaz09-_.~".repeat(10000);
assert_eq!(super::svg_data_uri(&run), format!("data:image/svg+xml;charset=utf-8,{run}"));
let text = format!("{run}é{run}%");
assert_eq!(super::svg_data_uri(&text), format!("data:image/svg+xml;charset=utf-8,{run}%C3%A9{run}%25"));
assert_eq!(super::escape_xml_text(&run), run);
assert_eq!(super::escape_xml_text("a&<>\"'é\r\n"), "a&<>"'é\r\n");
assert_eq!(super::escape_xml_text("é\r\n"), "é\r\n");
}
}