use std::borrow::Cow;
use encoding_rs::WINDOWS_1252;
const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
const UTF16_LE_BOM: [u8; 2] = [0xFF, 0xFE];
const UTF16_BE_BOM: [u8; 2] = [0xFE, 0xFF];
pub fn decode_paradox_bytes(bytes: &[u8]) -> Cow<'_, str> {
if let Some(rest) = bytes.strip_prefix(UTF8_BOM.as_slice()) {
return Cow::Owned(String::from_utf8_lossy(rest).into_owned());
}
if bytes.starts_with(UTF16_LE_BOM.as_slice()) {
let (decoded, _, _) = encoding_rs::UTF_16LE.decode(&bytes[2..]);
return Cow::Owned(decoded.into_owned());
}
if bytes.starts_with(UTF16_BE_BOM.as_slice()) {
let (decoded, _, _) = encoding_rs::UTF_16BE.decode(&bytes[2..]);
return Cow::Owned(decoded.into_owned());
}
if let Ok(text) = std::str::from_utf8(bytes) {
return Cow::Borrowed(text);
}
let (decoded, _, _) = WINDOWS_1252.decode(bytes);
Cow::Owned(decoded.into_owned())
}
pub fn encode_paradox_text(text: &str) -> Cow<'_, [u8]> {
if text.is_ascii() {
return Cow::Borrowed(text.as_bytes());
}
let (encoded, _, unmappable) = WINDOWS_1252.encode(text);
if unmappable {
Cow::Borrowed(text.as_bytes())
} else {
Cow::Owned(encoded.into_owned())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TextEdit {
pub offset: usize,
pub remove: usize,
pub insert: &'static str,
}
pub fn edit_paradox_bytes(bytes: &[u8], edits: &[TextEdit]) -> Option<Vec<u8>> {
let mut edited = decode_paradox_bytes(bytes).into_owned();
let mut ordered = edits.to_vec();
ordered.sort_by_key(|edit| std::cmp::Reverse(edit.offset));
for edit in ordered {
let end = edit.offset.checked_add(edit.remove)?;
if end > edited.len()
|| !edited.is_char_boundary(edit.offset)
|| !edited.is_char_boundary(end)
{
return None;
}
edited.replace_range(edit.offset..end, edit.insert);
}
reencode_paradox_bytes(bytes, &edited)
}
pub fn reencode_paradox_bytes(bytes: &[u8], text: &str) -> Option<Vec<u8>> {
if bytes.starts_with(UTF16_LE_BOM.as_slice()) || bytes.starts_with(UTF16_BE_BOM.as_slice()) {
return None;
}
let (prefix, body) = match bytes.strip_prefix(UTF8_BOM.as_slice()) {
Some(rest) => (UTF8_BOM.as_slice(), rest),
None => (&[][..], bytes),
};
let encoding = if std::str::from_utf8(body).is_ok() {
encoding_rs::UTF_8
} else if !prefix.is_empty() {
return None;
} else {
WINDOWS_1252
};
let (decoded, had_errors) = encoding.decode_without_bom_handling(body);
if had_errors || decoded != decode_paradox_bytes(bytes) {
return None;
}
let (round_trip, _, unmappable) = encoding.encode(&decoded);
if unmappable || round_trip.as_ref() != body {
return None;
}
let (encoded, _, unmappable) = encoding.encode(text);
if unmappable {
return None;
}
let mut out = prefix.to_vec();
out.extend_from_slice(&encoded);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_ascii_borrows() {
let bytes = b"name = \"Catherine\"";
let out = decode_paradox_bytes(bytes);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out, "name = \"Catherine\"");
}
#[test]
fn utf8_with_combining_chars_borrows() {
let bytes = b"dynasty = \"de Bragan\xc3\xa7a\"";
let out = decode_paradox_bytes(bytes);
assert!(matches!(out, Cow::Borrowed(_)));
assert!(out.contains("Bragança"));
}
#[test]
fn windows_1252_high_byte_falls_back_losslessly() {
let bytes = b"dynasty = \"de Bragan\xe7a\"";
let out = decode_paradox_bytes(bytes);
assert_eq!(out, "dynasty = \"de Bragança\"");
}
#[test]
fn utf8_and_windows_1252_converge_for_same_logical_text() {
let utf8 = b"\xc3\xa7\xc3\xa9\xc3\xb1";
let win1252 = b"\xe7\xe9\xf1";
assert_eq!(decode_paradox_bytes(utf8), decode_paradox_bytes(win1252));
}
#[test]
fn utf8_bom_is_stripped() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"name = yes");
assert_eq!(decode_paradox_bytes(&bytes), "name = yes");
}
#[test]
fn utf16_le_bom_decodes() {
let bytes = [0xFF, 0xFE, b'a', 0x00, b'b', 0x00];
assert_eq!(decode_paradox_bytes(&bytes), "ab");
}
#[test]
fn utf16_be_bom_decodes() {
let bytes = [0xFE, 0xFF, 0x00, b'a', 0x00, b'b'];
assert_eq!(decode_paradox_bytes(&bytes), "ab");
}
#[test]
fn translation_escape_bytes_round_trip_unchanged() {
let bytes = b"dynasty = \"\x10\xafQ \x10\xc8T\x10\x03^\x10\xafe\x10!X\"";
let decoded = decode_paradox_bytes(bytes);
assert_eq!(encode_paradox_text(&decoded).as_ref(), bytes);
}
#[test]
fn legacy_cjk_bytes_round_trip_unchanged() {
let bytes = b"name = \"\xbf\xad\xc9\xaa\xc1\xd5\"";
let decoded = decode_paradox_bytes(bytes);
assert_eq!(encode_paradox_text(&decoded).as_ref(), bytes);
}
#[test]
fn text_without_a_windows_1252_spelling_is_written_as_utf8() {
let text = "name = \"凯瑟琳\"";
assert_eq!(encode_paradox_text(text).as_ref(), text.as_bytes());
assert_eq!(
encode_paradox_text("dynasty = \"de Bragança\"").as_ref(),
b"dynasty = \"de Bragan\xe7a\""
);
}
#[test]
fn windows_1252_only_path_still_falls_back() {
let bytes = b"\xe7";
assert_eq!(decode_paradox_bytes(bytes), "ç");
}
#[test]
fn edits_keep_a_windows_1252_file_in_its_encoding() {
let bytes = b"a = { name = \"Bragan\xe7a\" }\n}\n";
let edited = edit_paradox_bytes(
bytes,
&[TextEdit {
offset: decode_paradox_bytes(bytes).rfind('}').unwrap(),
remove: 1,
insert: "",
}],
)
.unwrap();
assert_eq!(edited, b"a = { name = \"Bragan\xe7a\" }\n\n");
}
#[test]
fn edits_keep_a_utf8_bom() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice("a = {\n\tx = \"ç\"\n".as_bytes());
let decoded = decode_paradox_bytes(&bytes).into_owned();
let edited = edit_paradox_bytes(
&bytes,
&[TextEdit {
offset: decoded.len(),
remove: 0,
insert: "}\n",
}],
)
.unwrap();
let mut expected = vec![0xEF, 0xBB, 0xBF];
expected.extend_from_slice("a = {\n\tx = \"ç\"\n}\n".as_bytes());
assert_eq!(edited, expected);
}
#[test]
fn utf16_is_never_edited() {
let bytes = [0xFF, 0xFE, b'a', 0];
assert!(edit_paradox_bytes(&bytes, &[]).is_none());
}
}