#[cfg(feature = "quality")]
pub mod quality;
#[cfg(feature = "quality")]
pub mod string_utils;
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn read_test_fixture(relative: &str) -> Option<Vec<u8>> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../test_documents")
.join(relative);
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(e) => {
eprintln!(
"SKIP: fixture {} not available ({e}); run `python3 test_documents/scripts/fetch_corpus.py` to fetch it",
path.display()
);
None
}
}
}
pub(crate) fn strip_bom(s: &str) -> &str {
s.strip_prefix('\u{FEFF}').unwrap_or(s)
}
pub mod json_utils;
pub mod markdown_utils;
pub mod string_pool;
pub mod xml_utils;
#[cfg(feature = "quality")]
pub(crate) use string_utils::safe_decode;
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) use xml_utils::xml_tag_name;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DecodeOutcome {
pub(crate) text: String,
pub(crate) fell_back: bool,
pub(crate) replaced_characters: bool,
}
#[cfg(feature = "quality")]
pub(crate) fn decode_with_provenance(byte_data: &[u8], encoding: Option<&str>) -> DecodeOutcome {
string_utils::safe_decode_with_provenance(byte_data, encoding)
}
#[cfg(not(feature = "quality"))]
pub(crate) fn decode_with_provenance(byte_data: &[u8], encoding: Option<&str>) -> DecodeOutcome {
if byte_data.is_empty() {
return DecodeOutcome {
text: String::new(),
fell_back: false,
replaced_characters: false,
};
}
if let Some(enc_name) = encoding
&& let Some(enc) = encoding_rs::Encoding::for_label(enc_name.as_bytes())
{
let (decoded, actual_encoding, had_errors) = enc.decode(byte_data);
return DecodeOutcome {
text: decoded.into_owned(),
fell_back: actual_encoding != encoding_rs::UTF_8,
replaced_characters: had_errors,
};
}
let decoded = String::from_utf8_lossy(byte_data);
let replaced_characters = matches!(decoded, std::borrow::Cow::Owned(_));
DecodeOutcome {
text: decoded.into_owned(),
fell_back: false,
replaced_characters,
}
}
#[cfg(any(
all(feature = "layout-detection", feature = "pdf"),
feature = "office",
feature = "markdown-footnotes"
))]
use std::borrow::Cow;
#[cfg(all(feature = "layout-detection", feature = "pdf"))]
#[inline]
pub(crate) fn escape_html_entities(text: &str) -> Cow<'_, str> {
let needs_amp = text.contains('&');
let needs_lt = text.contains('<');
let needs_gt = text.contains('>');
if !needs_amp && !needs_lt && !needs_gt {
return Cow::Borrowed(text);
}
let mut result = String::with_capacity(text.len() + 16);
for ch in text.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
_ => result.push(ch),
}
}
Cow::Owned(result)
}
#[cfg(any(feature = "office", feature = "markdown-footnotes"))]
#[inline]
#[cfg_attr(alef, alef(skip))]
pub(crate) fn normalize_whitespace(s: &str) -> Cow<'_, str> {
let needs_normalization = s
.as_bytes()
.windows(2)
.any(|w| w[0].is_ascii_whitespace() && w[1].is_ascii_whitespace())
|| s.bytes().any(|b| b != b' ' && b.is_ascii_whitespace())
|| s.as_bytes().first().is_some_and(u8::is_ascii_whitespace)
|| s.as_bytes().last().is_some_and(u8::is_ascii_whitespace);
if needs_normalization {
Cow::Owned(s.split_whitespace().collect::<Vec<_>>().join(" "))
} else {
Cow::Borrowed(s)
}
}
#[cfg(all(test, any(feature = "office", feature = "markdown-footnotes")))]
mod normalize_whitespace_tests {
use super::*;
#[test]
fn borrows_when_already_normalized() {
let result = normalize_whitespace("already normal");
assert!(matches!(result, Cow::Borrowed(_)), "should not allocate when unchanged");
assert_eq!(result, "already normal");
}
#[test]
fn borrows_empty_string() {
assert!(matches!(normalize_whitespace(""), Cow::Borrowed(_)));
}
#[test]
fn allocates_and_collapses_internal_whitespace_runs() {
let result = normalize_whitespace("a b\tc\nd");
assert!(matches!(result, Cow::Owned(_)));
assert_eq!(result, "a b c d");
}
#[test]
fn allocates_and_trims_single_leading_and_trailing_space() {
let result = normalize_whitespace(" a b ");
assert!(
matches!(result, Cow::Owned(_)),
"leading/trailing space must not take the borrow fast path"
);
assert_eq!(result, "a b");
}
}
#[cfg(test)]
mod decode_provenance_tests {
use super::*;
#[test]
fn valid_utf8_input_reports_no_fallback_and_no_replacement() {
let input = "Hello, 世界! مرحبا".as_bytes();
let outcome = decode_with_provenance(input, None);
assert_eq!(outcome.text, "Hello, 世界! مرحبا");
assert!(!outcome.fell_back, "valid UTF-8 must not report a fallback");
assert!(
!outcome.replaced_characters,
"valid UTF-8 must not report a replacement"
);
#[cfg(feature = "quality")]
assert_eq!(
outcome.text,
safe_decode(input, None),
"decode_with_provenance must not change the decoded text"
);
}
#[test]
fn windows_1252_reinterpretation_reports_fallback_without_replacement() {
let input: &[u8] = &[b'r', 0xE9, b's', b'u', b'm', 0xE9];
let outcome = decode_with_provenance(input, Some("windows-1252"));
assert_eq!(outcome.text, "résumé");
assert!(
outcome.fell_back,
"windows-1252 is not UTF-8, so this must report a fallback"
);
assert!(
!outcome.replaced_characters,
"windows-1252 maps every byte 0x00-0xFF, so no replacement character can occur"
);
#[cfg(feature = "quality")]
assert_eq!(
outcome.text,
safe_decode(input, Some("windows-1252")),
"decode_with_provenance must not change the decoded text"
);
}
#[test]
fn forced_replacement_reports_replacement_in_both_builds() {
let input: &[u8] = &[b'A', 0xFF, 0xFE, b'B'];
let outcome = decode_with_provenance(input, Some("utf-8"));
#[cfg(feature = "quality")]
assert_eq!(
outcome.text, "AB",
"quality's mojibake cleanup strips U+FFFD -- pre-existing behavior"
);
#[cfg(not(feature = "quality"))]
assert_eq!(
outcome.text, "A\u{FFFD}\u{FFFD}B",
"non-quality leaves U+FFFD in place -- pre-existing behavior"
);
assert!(!outcome.fell_back, "UTF-8 was used, so this must not report a fallback");
assert!(
outcome.replaced_characters,
"undecodable bytes must be reported as a replacement in both builds"
);
#[cfg(feature = "quality")]
assert_eq!(
outcome.text,
safe_decode(input, Some("utf-8")),
"decode_with_provenance must not change the decoded text"
);
}
#[test]
fn empty_input_reports_no_fallback_and_no_replacement() {
let outcome = decode_with_provenance(b"", None);
assert_eq!(outcome.text, "");
assert!(!outcome.fell_back);
assert!(!outcome.replaced_characters);
}
}