///|
/// XML escaping is shared with the `ooxml` package (the OOXML packaging layer
/// this package builds on) so there is a single implementation to keep
/// correct. These thin wrappers preserve the local names used throughout the
/// writer.
fn escape_xml_text(value : StringView) -> String {
@ooxml.escape_xml_text(value)
}
///|
fn escape_xml_attr(value : StringView) -> String {
@ooxml.escape_xml_attr(value)
}
///|
fn is_valid_xml_char_reference(value : Int) -> Bool {
value == 0x09 ||
value == 0x0a ||
value == 0x0d ||
(value >= 0x20 && value <= 0xd7ff) ||
(value >= 0xe000 && value <= 0xfffd) ||
(value >= 0x10000 && value <= 0x10ffff)
}
///|
fn xml_entity_digits_are_valid(digits : StringView, base : Int) -> Bool {
if digits == "" {
return false
}
for digit in digits {
let valid = if base == 10 {
digit.is_ascii_digit()
} else {
digit.is_ascii_digit() || digit is ('a'..='f') || digit is ('A'..='F')
}
if !valid {
return false
}
}
true
}
///|
fn decode_xml_entity(entity : StringView) -> Char? {
match entity {
"lt" => Some('<')
"gt" => Some('>')
"quot" => Some('"')
"apos" => Some('\'')
"amp" => Some('&')
_ => {
let text = entity.to_owned()
let (digits, base) = if text.has_prefix("#x") || text.has_prefix("#X") {
(text[2:], 16)
} else if text.has_prefix("#") {
(text[1:], 10)
} else {
return None
}
if !xml_entity_digits_are_valid(digits, base) {
return None
}
let codepoint = @string.parse_int(digits, base~) catch {
_ => return None
}
if !is_valid_xml_char_reference(codepoint) {
return None
}
Some(codepoint.unsafe_to_char())
}
}
}
///|
/// Decode predefined and numeric XML entities in one pass. A one-pass decoder
/// is important here: ` ` denotes the literal text ` `, not a space.
/// Unknown or malformed entities are preserved for the owning parser to reject
/// according to its field grammar.
fn unescape_xml_text(value : StringView) -> String {
if !value.contains("&") {
return value.to_owned()
}
let output = StringBuilder::new()
let mut index = 0
let mut literal_start = 0
while index < value.length() {
if value[index] != ('&' : UInt16) {
index = index + 1
continue
}
let mut entity_end = index + 1
// Stop at a nested ampersand or markup opener. This keeps ampersand-heavy
// malformed input linear while still accepting valid numeric references
// with arbitrarily many leading zeroes.
while entity_end < value.length() &&
value[entity_end] != (';' : UInt16) &&
value[entity_end] != ('&' : UInt16) &&
value[entity_end] != ('<' : UInt16) {
entity_end = entity_end + 1
}
if entity_end < value.length() && value[entity_end] == (';' : UInt16) {
match decode_xml_entity(value[index + 1:entity_end]) {
Some(character) => {
output.write_view(value[literal_start:index])
output.write_char(character)
index = entity_end + 1
literal_start = index
continue
}
None => ()
}
}
index = index + 1
}
output.write_view(value[literal_start:])
output.to_string()
}
///|
fn is_attr_space(ch : Char) -> Bool {
match ch {
' ' | '\t' | '\n' | '\r' => true
_ => false
}
}
///|
fn attr_value(tag : StringView, name : StringView) -> String? raise XlsxError {
@ooxml.attr_value(tag, name) catch {
InvalidXml(msg~) => raise InvalidXml(msg~)
ReadCancelled => raise ReadCancelled
}
}
///|
test "xml wb: escape attr and attr-space branches" {
inspect(escape_xml_attr("a>b"), content="a>b")
inspect(is_attr_space('\t'), content="true")
}
///|
test "xml wb: unescape numeric entities once and preserve invalid entities" {
inspect(unescape_xml_text("A1 B1 C1"), content="A1 B1 C1")
inspect(unescape_xml_text("A1 B1"), content="A1 B1")
inspect(unescape_xml_text("😀"), content="😀")
inspect(unescape_xml_text(" "), content=" ")
inspect(unescape_xml_text("&unknown; "), content="&unknown; ")
}
///|
test "xml wb: attr_value parser edge branches" {
debug_inspect(
attr_value("= \"skip\" foo= \"bar\"", "foo"),
content="Some(\"bar\")",
)
debug_inspect(attr_value("foo=bar", "foo"), content="None")
let invalid : Result[String?, Error] = Ok(
attr_value("foo=\"unterminated", "foo"),
) catch {
e => Err(e)
}
inspect(invalid is Err(XlsxError::InvalidXml(_)), content="true")
}