xberg 1.3.10

High-performance document intelligence library for Rust. Extract text, metadata, and structured data from PDFs, Office documents, images, and 107 formats and 371 programming languages via tree-sitter code intelligence with async/sync APIs.
Documentation
//! Image metadata and data extraction from RTF documents.

use crate::extractors::rtf::encoding::parse_rtf_control_word;

/// Parsed image data from a `\pict` group.
pub struct RtfImage {
    /// Image format string (e.g., "jpeg", "png", "wmf", "bmp").
    pub format: &'static str,
    /// Decoded binary image data.
    pub data: Vec<u8>,
}

/// Extract image metadata and binary data from within a `\pict` group.
///
/// Parses the image type (`\jpegblip`, `\pngblip`, etc.), dimensions, and
/// collects the hex-encoded image data that follows the control words.
/// Returns the parsed image and a metadata string for text representation.
/// Skip a `\bin` payload of `count` SOURCE bytes.
///
/// `count` is a number of BYTES of the `\bin` payload, not a number of
/// `char`s. Consuming `count` `chars.next()` calls OVER-consumes as
/// soon as the payload holds any multi-byte character, running past
/// the payload and swallowing the document text that follows it.
///
/// Charging each character its *source* byte width instead requires
/// one correction: this stream comes from `String::from_utf8_lossy`
/// (rtf/mod.rs), which rewrites each undecodable byte as U+FFFD — 3
/// bytes encoded, but 1 byte in the file. A raw `\bin` payload is
/// arbitrary binary, so it decodes almost entirely to U+FFFD;
/// charging those 3 bytes each would stop after roughly a third of
/// the payload and spill the remainder into the parser as RTF text.
/// Charging them 1 byte is exact for the overwhelmingly common
/// single-invalid-byte case.
///
/// Residual limitation: `from_utf8_lossy` emits one U+FFFD per
/// maximal ill-formed subsequence, so a multi-byte truncated sequence
/// also collapses to a single U+FFFD and is under-counted here. Only
/// parsing `\bin` against the original bytes can be exact, and this
/// parser is char-based throughout. ~keep
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,
        }
    }
}

/// Apply one `\pict`-group control word's effect on the in-progress image parse state.
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;
            }
        }
        _ => {}
    }
}

/// Build the `image.<ext>` metadata string from the detected image type, defaulting to jpg.
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
}

/// Decode the collected hex digits into binary image data, if any were collected.
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::*;

    /// A `\bin` payload that contains a multi-byte UTF-8 character (once the RTF source has
    /// already gone through `String::from_utf8_lossy`) must be skipped by BYTE count, not by
    /// `char` count. `\bin4` declares a 4-byte payload; here it is represented by the 2-byte
    /// char `é` followed by the 1-byte chars `A` and `B` (2 + 1 + 1 = 4 bytes, but only 3
    /// `char`s). Consuming 4 `chars.next()` calls (the old, buggy behavior) eats one char too
    /// many — the group's own closing `}` — which desynchronizes group tracking: the caller
    /// never sees that `}`, the parser never terminates the `\pict` group, and it keeps
    /// scanning into "AFTER" until the input is exhausted, losing it entirely. The fixed,
    /// byte-counting version leaves the closing `}` (and everything after it, "AFTER")
    /// untouched for the caller.
    #[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"
        );
    }

    /// The dominant real-world shape: a `\bin` payload of raw binary. `rtf/mod.rs` decodes the
    /// file with `String::from_utf8_lossy`, so each undecodable byte arrives as U+FFFD — encoded
    /// as 3 bytes but only 1 byte in the source file. Charging U+FFFD its *encoded* width
    /// satisfies the declared count after a third of the payload, leaving the remainder to be
    /// read as RTF markup.
    ///
    /// The payload here deliberately contains a `}` byte. Under correct accounting it is opaque
    /// payload and is consumed; under-consume and the parser meets it as a group terminator,
    /// ends the `\pict` group early, and hands the caller the tail of the binary blob as if it
    /// were document text. Without that embedded `}` the surrounding parser happens to absorb
    /// the leftover bytes anyway and the bug hides.
    #[test]
    fn should_charge_replacement_characters_one_source_byte_each() {
        let replacement = char::REPLACEMENT_CHARACTER;
        // Six source bytes: two undecodable, a literal `}`, then three more undecodable.
        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");
    }
}