use crate::extractors::rtf::encoding::parse_rtf_control_word;
pub struct RtfImage {
pub format: &'static str,
pub data: Vec<u8>,
}
fn consume_bin_payload(chars: &mut std::iter::Peekable<std::str::Chars>, count: usize) {
let mut consumed_bytes = 0usize;
while consumed_bytes < count {
match chars.next() {
Some(consumed_char) => {
consumed_bytes += if consumed_char == char::REPLACEMENT_CHARACTER {
1
} else {
consumed_char.len_utf8()
};
}
None => break,
}
}
}
fn apply_pict_control_word(
chars: &mut std::iter::Peekable<std::str::Chars>,
control_word: &str,
value: Option<i32>,
image_type: &mut Option<&'static str>,
format: &mut &'static str,
has_bin: &mut bool,
) {
match control_word {
"jpegblip" => {
*image_type = Some("jpg");
*format = "jpeg";
}
"pngblip" => {
*image_type = Some("png");
*format = "png";
}
"wmetafile" => {
*image_type = Some("wmf");
*format = "wmf";
}
"dibitmap" => {
*image_type = Some("bmp");
*format = "bmp";
}
"picwgoal" | "pichgoal" => {}
"bin" => {
if let Some(count) = value {
let count = count.max(0) as usize;
consume_bin_payload(chars, count);
*has_bin = true;
}
}
_ => {}
}
}
fn build_pict_metadata(image_type: Option<&str>) -> String {
let mut metadata = String::new();
if let Some(itype) = image_type {
metadata.push_str("image.");
metadata.push_str(itype);
}
if metadata.is_empty() {
metadata.push_str("image.jpg");
}
metadata
}
fn decode_pict_image(hex_chars: &str, format: &'static str) -> Option<RtfImage> {
if hex_chars.is_empty() {
return None;
}
match hex::decode(hex_chars) {
Ok(data) if !data.is_empty() => Some(RtfImage { format, data }),
_ => None,
}
}
pub(crate) fn extract_pict_image(chars: &mut std::iter::Peekable<std::str::Chars>) -> (String, Option<RtfImage>) {
let mut image_type: Option<&str> = None;
let mut format: &str = "jpeg";
let mut depth = 0;
let mut hex_chars = String::new();
let mut _has_bin = false;
while let Some(&ch) = chars.peek() {
match ch {
'{' => {
depth += 1;
chars.next();
}
'}' => {
if depth == 0 {
break;
}
depth -= 1;
chars.next();
}
'\\' => {
chars.next();
if matches!(chars.peek(), Some(&('{' | '}' | '\\'))) {
chars.next();
continue;
}
let (control_word, value) = parse_rtf_control_word(chars);
apply_pict_control_word(
chars,
control_word.as_str(),
value,
&mut image_type,
&mut format,
&mut _has_bin,
);
}
' ' | '\r' | '\n' => {
chars.next();
}
_ => {
if depth == 0 && ch.is_ascii_hexdigit() {
hex_chars.push(ch);
}
chars.next();
}
}
}
let metadata = build_pict_metadata(image_type);
let image = decode_pict_image(&hex_chars, format);
(metadata, image)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_skip_bin_payload_by_byte_count_not_char_count() {
let input = "\\jpegblip\\bin4\u{00e9}AB}AFTER";
let mut chars = input.chars().peekable();
let (metadata, image) = extract_pict_image(&mut chars);
assert_eq!(metadata, "image.jpg");
assert!(image.is_none());
let remaining: String = chars.collect();
assert_eq!(
remaining, "}AFTER",
"the group's closing brace and the trailing document text must survive `\\bin` skipping"
);
}
#[test]
fn should_charge_replacement_characters_one_source_byte_each() {
let replacement = char::REPLACEMENT_CHARACTER;
let payload = format!("{replacement}{replacement}}}{replacement}{replacement}{replacement}");
let input = format!("\\jpegblip\\bin6{payload}}}AFTER");
let mut chars = input.chars().peekable();
let (metadata, image) = extract_pict_image(&mut chars);
assert_eq!(metadata, "image.jpg");
assert!(image.is_none());
let remaining: String = chars.collect();
assert_eq!(
remaining, "}AFTER",
"the six-byte payload — including its embedded `}}` byte — must be consumed whole, \
leaving only the group's real closing brace and the document text after it"
);
}
#[test]
fn should_ignore_picture_property_group_when_decoding_hex_payload() {
let input = concat!(
r"{\*\picprop{\sp{\sn wzDescription}{\sv label}}{\sp{\sn wzName}{\sv picture.png}}}",
r"\picw1\pich1\pngblip 89504e47}AFTER"
);
let mut chars = input.chars().peekable();
let (metadata, image) = extract_pict_image(&mut chars);
assert_eq!(metadata, "image.png");
assert_eq!(image.expect("the root-level hex payload must decode").data, b"\x89PNG");
assert_eq!(chars.collect::<String>(), "}AFTER");
}
#[test]
fn should_ignore_escaped_braces_inside_picture_property_values() {
let input = concat!(
r"{\*\picprop{\sp{\sn wzDescription}{\sv label \{draft}}}",
r"\pngblip 89504e47}AFTER"
);
let mut chars = input.chars().peekable();
let (metadata, image) = extract_pict_image(&mut chars);
assert_eq!(metadata, "image.png");
assert_eq!(image.expect("the root-level hex payload must decode").data, b"\x89PNG");
assert_eq!(chars.collect::<String>(), "}AFTER");
}
}