Skip to main content

document_svg/document/
rtf.rs

1//! Bounded text preview for Rich Text Format (RTF) documents.
2//!
3//! Text, Unicode escapes, paragraphs, tabs, and common ANSI escapes are kept.
4//! Formatting is flattened to the shared page composer; bounded embedded PNG/JPEG
5//! pictures are shown as flow blocks, while objects, other picture types, headers,
6//! and advanced layout are reported as omissions.
7
8use std::fs::File;
9use std::io::Read;
10use std::path::Path;
11
12use base64::Engine;
13use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
14use encoding_rs::Encoding;
15
16use crate::convert::{ConvertOptions, PageConsumer};
17use crate::document::html::{HtmlBlock, render_blocks_to_pages};
18use crate::error::{Error, Result};
19use crate::ooxml::sniff_image_mime;
20
21const MAX_RTF_GROUP_DEPTH: usize = 256;
22const MAX_RTF_TOKENS: usize = 10_000_000;
23const MAX_RTF_BLOCKS: usize = 200_000;
24const MAX_RTF_TEXT_BYTES: usize = 32 * 1024 * 1024;
25const MAX_RTF_BINARY_SKIP: usize = 64 * 1024 * 1024;
26const MAX_RTF_IMAGE_REFERENCES: usize = 10_000;
27const MAX_RTF_IMAGE_BYTES: usize = 8 * 1024 * 1024;
28const MAX_RTF_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
29const MAX_RTF_TOTAL_DATA_URI_BYTES: usize = 48 * 1024 * 1024;
30const MAX_RTF_IMAGE_PIXELS: u64 = 40_000_000;
31const MAX_RTF_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
32const MAX_RTF_SCALE_PERCENT: u32 = 10_000;
33const MAX_RTF_DISPLAY_SIDE: f64 = 100_000.0;
34
35#[derive(Clone, Debug)]
36struct RtfState {
37    skip: bool,
38    ignorable_destination: bool,
39    unicode_fallback: usize,
40    codepage: u16,
41}
42
43#[derive(Default)]
44struct RtfImageBudget {
45    references: usize,
46    image_bytes: usize,
47    data_uri_bytes: usize,
48    pixels: u64,
49}
50
51#[derive(Default)]
52struct RtfPictureCapture {
53    mime: Option<&'static str>,
54    bytes: Vec<u8>,
55    pending_nibble: Option<u8>,
56    decoded_byte_count: usize,
57    exceeded_limit: bool,
58    malformed: bool,
59    width_goal_twips: Option<u32>,
60    height_goal_twips: Option<u32>,
61    scale_x_percent: Option<u32>,
62    scale_y_percent: Option<u32>,
63    invalid_dimensions: bool,
64}
65
66impl Default for RtfState {
67    fn default() -> Self {
68        Self {
69            skip: false,
70            ignorable_destination: false,
71            unicode_fallback: 1,
72            codepage: 1252,
73        }
74    }
75}
76
77pub(crate) fn convert(
78    path: &Path,
79    options: &ConvertOptions,
80    sink: &mut dyn PageConsumer,
81) -> Result<Vec<String>> {
82    let mut file = File::open(path)?;
83    let mut bytes = Vec::new();
84    Read::take(&mut file, options.max_input_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
85    if bytes.len() as u64 > options.max_input_bytes {
86        return Err(Error::LimitExceeded(format!(
87            "RTF input exceeds maximum bytes ({})",
88            options.max_input_bytes
89        )));
90    }
91    let (blocks, mut warnings) = parse_rtf(&bytes)?;
92    if !blocks
93        .iter()
94        .any(|block| !matches!(block, HtmlBlock::PageBreak | HtmlBlock::HorizontalRule))
95    {
96        return Err(Error::InvalidInput(
97            "RTF document contains no renderable text".into(),
98        ));
99    }
100    warnings.insert(
101        0,
102        "RTF character/paragraph formatting, tables, page geometry, and section pagination are approximated".into(),
103    );
104    render_blocks_to_pages(&blocks, sink, options)?;
105    Ok(warnings)
106}
107
108fn parse_rtf(bytes: &[u8]) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
109    let mut index = 0usize;
110    while bytes.get(index).is_some_and(|byte| {
111        byte.is_ascii_whitespace() || *byte == 0xef || *byte == 0xbb || *byte == 0xbf
112    }) {
113        index += 1;
114    }
115    if bytes.get(index..index + 5) != Some(b"{\\rtf") {
116        return Err(Error::InvalidInput(
117            "input does not start with a valid RTF header".into(),
118        ));
119    }
120
121    let mut state = RtfState::default();
122    let mut stack = Vec::new();
123    let mut blocks = Vec::new();
124    let mut paragraph = String::new();
125    let mut ansi_buffer = Vec::new();
126    let mut output_bytes = 0usize;
127    let mut token_count = 0usize;
128    let mut fallback_to_skip = 0usize;
129    let mut pending_high_surrogate = None;
130    let mut saw_root = false;
131    let mut saw_root_close = false;
132    let mut warned_media = false;
133    let mut warned_layout = false;
134    let mut warned_codepage = false;
135    let mut warned_invalid_encoding = false;
136    let mut warned_break = false;
137    let mut warned_picture_layout = false;
138    let mut image_budget = RtfImageBudget::default();
139    let mut warnings = Vec::new();
140
141    while index < bytes.len() {
142        token_count = token_count.saturating_add(1);
143        if token_count > MAX_RTF_TOKENS {
144            return Err(Error::LimitExceeded(format!(
145                "RTF token count exceeds {MAX_RTF_TOKENS}"
146            )));
147        }
148        match bytes[index] {
149            b'{' => {
150                flush_ansi_buffer(
151                    &mut ansi_buffer,
152                    state.codepage,
153                    &mut paragraph,
154                    &mut output_bytes,
155                    &mut warnings,
156                    &mut warned_invalid_encoding,
157                )?;
158                if stack.len() >= MAX_RTF_GROUP_DEPTH {
159                    return Err(Error::LimitExceeded(format!(
160                        "RTF group depth exceeds {MAX_RTF_GROUP_DEPTH}"
161                    )));
162                }
163                stack.push(state.clone());
164                if !saw_root {
165                    saw_root = true;
166                }
167                index += 1;
168            }
169            b'}' => {
170                flush_ansi_buffer(
171                    &mut ansi_buffer,
172                    state.codepage,
173                    &mut paragraph,
174                    &mut output_bytes,
175                    &mut warnings,
176                    &mut warned_invalid_encoding,
177                )?;
178                state = stack.pop().ok_or_else(|| {
179                    Error::InvalidInput("RTF contains an unmatched closing brace".into())
180                })?;
181                index += 1;
182                if stack.is_empty() {
183                    saw_root_close = true;
184                    break;
185                }
186            }
187            b'\\' => {
188                index += 1;
189                let Some(&next) = bytes.get(index) else {
190                    return Err(Error::InvalidInput("RTF ends after a backslash".into()));
191                };
192                if next.is_ascii_alphabetic() {
193                    let start = index;
194                    while bytes.get(index).is_some_and(u8::is_ascii_alphabetic) {
195                        index += 1;
196                    }
197                    let word = std::str::from_utf8(&bytes[start..index])
198                        .map_err(|_| Error::InvalidInput("RTF control word is not ASCII".into()))?
199                        .to_ascii_lowercase();
200                    let parameter_start = index;
201                    if bytes.get(index) == Some(&b'-') {
202                        index += 1;
203                    }
204                    while bytes.get(index).is_some_and(u8::is_ascii_digit) {
205                        index += 1;
206                    }
207                    let parameter = if index > parameter_start {
208                        std::str::from_utf8(&bytes[parameter_start..index])
209                            .ok()
210                            .and_then(|value| value.parse::<i32>().ok())
211                    } else {
212                        None
213                    };
214                    if bytes.get(index) == Some(&b' ') {
215                        index += 1;
216                    }
217                    if word == "shppict" && !state.skip {
218                        // Word wraps its preferred picture in an ignorable
219                        // destination. Parse the nested \pict group instead
220                        // of discarding the whole wrapper.
221                        state.ignorable_destination = false;
222                        continue;
223                    }
224                    if word == "pict" && !state.skip {
225                        state.ignorable_destination = false;
226                        flush_ansi_buffer(
227                            &mut ansi_buffer,
228                            state.codepage,
229                            &mut paragraph,
230                            &mut output_bytes,
231                            &mut warnings,
232                            &mut warned_invalid_encoding,
233                        )?;
234                        flush_pending_surrogate(
235                            &mut pending_high_surrogate,
236                            &mut paragraph,
237                            &mut output_bytes,
238                        )?;
239                        flush_paragraph(&mut paragraph, &mut blocks)?;
240                        check_block_limit(&blocks)?;
241                        image_budget.references = image_budget.references.saturating_add(1);
242                        let can_capture = image_budget.references <= MAX_RTF_IMAGE_REFERENCES;
243                        if !can_capture {
244                            push_rtf_warning_once(
245                                &mut warnings,
246                                "RTF picture references exceeded the supported limit; remaining pictures were omitted",
247                            );
248                        }
249                        if !warned_picture_layout {
250                            warnings.push(
251                                "RTF picture anchors, crop, and text wrapping are approximated as centered flow blocks; declared goal size is used when present".into(),
252                            );
253                            warned_picture_layout = true;
254                        }
255                        let (closing_brace, picture, scanned_bytes) =
256                            read_rtf_picture(bytes, index, can_capture)?;
257                        token_count = token_count.saturating_add(scanned_bytes);
258                        if token_count > MAX_RTF_TOKENS {
259                            return Err(Error::LimitExceeded(format!(
260                                "RTF token count exceeds {MAX_RTF_TOKENS}"
261                            )));
262                        }
263                        index = closing_brace.saturating_add(1);
264                        state = stack.pop().ok_or_else(|| {
265                            Error::InvalidInput(
266                                "RTF picture group has no matching opening brace".into(),
267                            )
268                        })?;
269                        if can_capture {
270                            attach_rtf_picture(
271                                picture,
272                                &mut image_budget,
273                                &mut warnings,
274                                &mut blocks,
275                            )?;
276                        }
277                        check_block_limit(&blocks)?;
278                        if stack.is_empty() {
279                            saw_root_close = true;
280                            break;
281                        }
282                        continue;
283                    }
284                    process_control_word(
285                        &word,
286                        parameter,
287                        bytes,
288                        &mut index,
289                        &mut state,
290                        &mut paragraph,
291                        &mut ansi_buffer,
292                        &mut blocks,
293                        &mut output_bytes,
294                        &mut fallback_to_skip,
295                        &mut pending_high_surrogate,
296                        &mut warnings,
297                        &mut warned_media,
298                        &mut warned_layout,
299                        &mut warned_codepage,
300                        &mut warned_invalid_encoding,
301                        &mut warned_break,
302                    )?;
303                } else if next == b'\'' {
304                    index += 1;
305                    let pair = bytes.get(index..index.saturating_add(2)).ok_or_else(|| {
306                        Error::InvalidInput("RTF hexadecimal escape is truncated".into())
307                    })?;
308                    let hex = std::str::from_utf8(pair).map_err(|_| {
309                        Error::InvalidInput("RTF hexadecimal escape is not ASCII".into())
310                    })?;
311                    let value = u8::from_str_radix(hex, 16).map_err(|_| {
312                        Error::InvalidInput("RTF hexadecimal escape is invalid".into())
313                    })?;
314                    index += 2;
315                    if fallback_to_skip > 0 {
316                        fallback_to_skip -= 1;
317                    } else if !state.skip {
318                        flush_pending_surrogate(
319                            &mut pending_high_surrogate,
320                            &mut paragraph,
321                            &mut output_bytes,
322                        )?;
323                        buffer_ansi_byte(&mut ansi_buffer, value, output_bytes)?;
324                    }
325                } else {
326                    index += 1;
327                    let character = match next {
328                        b'\\' => Some('\\'),
329                        b'{' => Some('{'),
330                        b'}' => Some('}'),
331                        b'~' => Some('\u{00a0}'),
332                        b'_' => Some('\u{2011}'),
333                        b'-' => None,
334                        b'*' => {
335                            flush_ansi_buffer(
336                                &mut ansi_buffer,
337                                state.codepage,
338                                &mut paragraph,
339                                &mut output_bytes,
340                                &mut warnings,
341                                &mut warned_invalid_encoding,
342                            )?;
343                            state.ignorable_destination = true;
344                            None
345                        }
346                        b'\r' | b'\n' => None,
347                        _ => None,
348                    };
349                    if let Some(character) = character {
350                        if fallback_to_skip > 0 {
351                            fallback_to_skip -= 1;
352                        } else if !state.skip {
353                            match character {
354                                special @ ('\\' | '{' | '}') => {
355                                    flush_pending_surrogate(
356                                        &mut pending_high_surrogate,
357                                        &mut paragraph,
358                                        &mut output_bytes,
359                                    )?;
360                                    buffer_ansi_byte(
361                                        &mut ansi_buffer,
362                                        special as u8,
363                                        output_bytes,
364                                    )?;
365                                }
366                                special => {
367                                    flush_ansi_buffer(
368                                        &mut ansi_buffer,
369                                        state.codepage,
370                                        &mut paragraph,
371                                        &mut output_bytes,
372                                        &mut warnings,
373                                        &mut warned_invalid_encoding,
374                                    )?;
375                                    flush_pending_surrogate(
376                                        &mut pending_high_surrogate,
377                                        &mut paragraph,
378                                        &mut output_bytes,
379                                    )?;
380                                    append_char(&mut paragraph, special, &mut output_bytes)?;
381                                }
382                            }
383                        }
384                    }
385                }
386            }
387            b'\r' | b'\n' => index += 1,
388            byte => {
389                index += 1;
390                if fallback_to_skip > 0 {
391                    fallback_to_skip -= 1;
392                } else if !state.skip {
393                    flush_pending_surrogate(
394                        &mut pending_high_surrogate,
395                        &mut paragraph,
396                        &mut output_bytes,
397                    )?;
398                    buffer_ansi_byte(&mut ansi_buffer, byte, output_bytes)?;
399                }
400            }
401        }
402    }
403
404    if !saw_root || !saw_root_close || !stack.is_empty() {
405        return Err(Error::InvalidInput(
406            "RTF document has incomplete groups".into(),
407        ));
408    }
409    if bytes[index..]
410        .iter()
411        .any(|byte| !byte.is_ascii_whitespace())
412    {
413        return Err(Error::InvalidInput(
414            "RTF contains trailing bytes after its root group".into(),
415        ));
416    }
417    flush_ansi_buffer(
418        &mut ansi_buffer,
419        state.codepage,
420        &mut paragraph,
421        &mut output_bytes,
422        &mut warnings,
423        &mut warned_invalid_encoding,
424    )?;
425    flush_pending_surrogate(
426        &mut pending_high_surrogate,
427        &mut paragraph,
428        &mut output_bytes,
429    )?;
430    flush_paragraph(&mut paragraph, &mut blocks)?;
431    Ok((blocks, warnings))
432}
433
434#[allow(clippy::too_many_arguments)]
435fn process_control_word(
436    word: &str,
437    parameter: Option<i32>,
438    bytes: &[u8],
439    index: &mut usize,
440    state: &mut RtfState,
441    paragraph: &mut String,
442    ansi_buffer: &mut Vec<u8>,
443    blocks: &mut Vec<HtmlBlock>,
444    output_bytes: &mut usize,
445    fallback_to_skip: &mut usize,
446    pending_high_surrogate: &mut Option<u16>,
447    warnings: &mut Vec<String>,
448    warned_media: &mut bool,
449    warned_layout: &mut bool,
450    warned_codepage: &mut bool,
451    warned_invalid_encoding: &mut bool,
452    warned_break: &mut bool,
453) -> Result<()> {
454    flush_ansi_buffer(
455        ansi_buffer,
456        state.codepage,
457        paragraph,
458        output_bytes,
459        warnings,
460        warned_invalid_encoding,
461    )?;
462    if state.ignorable_destination {
463        state.skip = true;
464        state.ignorable_destination = false;
465    }
466    if is_rtf_destination(word) {
467        state.skip = true;
468        if matches!(
469            word,
470            "pict" | "object" | "objdata" | "shppict" | "nonshppict"
471        ) && !*warned_media
472        {
473            warnings.push("RTF embedded pictures and objects are omitted".into());
474            *warned_media = true;
475        }
476        if matches!(
477            word,
478            "header" | "footer" | "headerl" | "headerr" | "footerl" | "footerr"
479        ) && !warnings
480            .iter()
481            .any(|warning| warning == "RTF headers and footers are omitted")
482        {
483            warnings.push("RTF headers and footers are omitted".into());
484        }
485        return Ok(());
486    }
487    if state.skip {
488        if word == "bin" {
489            skip_binary(parameter, bytes, index)?;
490            return Ok(());
491        }
492        return Ok(());
493    }
494
495    match word {
496        "u" => {
497            let value = parameter.ok_or_else(|| {
498                Error::InvalidInput("RTF Unicode escape is missing its value".into())
499            })?;
500            if !(-32_768..=65_535).contains(&value) {
501                return Err(Error::InvalidInput(
502                    "RTF Unicode escape is outside a UTF-16 code unit".into(),
503                ));
504            }
505            let unit = value as u16;
506            append_utf16_unit(unit, pending_high_surrogate, paragraph, output_bytes)?;
507            *fallback_to_skip = state.unicode_fallback;
508        }
509        "uc" => {
510            let value = parameter.unwrap_or(1);
511            state.unicode_fallback = usize::try_from(value.clamp(0, 32)).unwrap_or(1);
512        }
513        "ansicpg" => {
514            let value = parameter.unwrap_or(1252).clamp(1, u16::MAX as i32) as u16;
515            state.codepage = value;
516            if encoding_for_codepage(value).is_none() && !*warned_codepage {
517                warnings.push(format!(
518                    "RTF ANSI code page {value} is unsupported; Windows-1252 fallback is used"
519                ));
520                *warned_codepage = true;
521            }
522        }
523        "par" | "row" => flush_paragraph(paragraph, blocks)?,
524        "line" => append_char(paragraph, '\n', output_bytes)?,
525        "tab" | "cell" => append_text(paragraph, "    ", output_bytes)?,
526        "page" => {
527            flush_paragraph(paragraph, blocks)?;
528            blocks.push(HtmlBlock::PageBreak);
529            check_block_limit(blocks)?;
530        }
531        "sect" => {
532            flush_paragraph(paragraph, blocks)?;
533            blocks.push(HtmlBlock::PageBreak);
534            check_block_limit(blocks)?;
535            if !*warned_break {
536                warnings.push("RTF section breaks are approximated as page breaks".into());
537                *warned_break = true;
538            }
539        }
540        "emdash" => append_char(paragraph, '\u{2014}', output_bytes)?,
541        "endash" => append_char(paragraph, '\u{2013}', output_bytes)?,
542        "bullet" => append_char(paragraph, '\u{2022}', output_bytes)?,
543        "lquote" => append_char(paragraph, '\u{2018}', output_bytes)?,
544        "rquote" => append_char(paragraph, '\u{2019}', output_bytes)?,
545        "ldblquote" => append_char(paragraph, '\u{201c}', output_bytes)?,
546        "rdblquote" => append_char(paragraph, '\u{201d}', output_bytes)?,
547        "bin" => {
548            if !*warned_media {
549                warnings.push("RTF embedded binary data is omitted".into());
550                *warned_media = true;
551            }
552            skip_binary(parameter, bytes, index)?;
553        }
554        "b" | "i" | "ul" | "ulnone" | "strike" | "fs" | "f" | "cf" | "highlight" | "pard"
555        | "plain" | "qc" | "ql" | "qr" | "qj" | "li" | "ri" | "fi" | "sb" | "sa" | "sl"
556        | "slmult" | "keep" | "keepn" | "widowctrl" | "viewkind" | "viewscale" | "deff"
557        | "deflang" | "deflangfe" | "lang" => {
558            if !*warned_layout {
559                warnings.push("RTF font, character, and paragraph styling is flattened".into());
560                *warned_layout = true;
561            }
562        }
563        _ => {}
564    }
565    Ok(())
566}
567
568fn is_rtf_destination(word: &str) -> bool {
569    matches!(
570        word,
571        "fonttbl"
572            | "colortbl"
573            | "stylesheet"
574            | "info"
575            | "title"
576            | "subject"
577            | "author"
578            | "manager"
579            | "company"
580            | "operator"
581            | "category"
582            | "keywords"
583            | "comment"
584            | "doccomm"
585            | "creatim"
586            | "revtim"
587            | "printim"
588            | "buptim"
589            | "header"
590            | "headerl"
591            | "headerr"
592            | "footer"
593            | "footerl"
594            | "footerr"
595            | "pict"
596            | "object"
597            | "objdata"
598            | "shppict"
599            | "nonshppict"
600            | "datastore"
601            | "themedata"
602            | "colorschememapping"
603            | "listtable"
604            | "listoverridetable"
605            | "revtbl"
606            | "generator"
607            | "xmlnstbl"
608            | "fldinst"
609            | "listtext"
610            | "annotation"
611            | "atnauthor"
612            | "atndate"
613            | "atnicn"
614    )
615}
616
617fn skip_binary(parameter: Option<i32>, bytes: &[u8], index: &mut usize) -> Result<()> {
618    let length = usize::try_from(parameter.ok_or_else(|| {
619        Error::InvalidInput("RTF binary payload is missing its byte count".into())
620    })?)
621    .map_err(|_| Error::InvalidInput("RTF binary payload length must be non-negative".into()))?;
622    if length > MAX_RTF_BINARY_SKIP {
623        return Err(Error::LimitExceeded(format!(
624            "RTF binary payload exceeds {MAX_RTF_BINARY_SKIP} bytes"
625        )));
626    }
627    let end = index
628        .checked_add(length)
629        .ok_or_else(|| Error::LimitExceeded("RTF binary payload length overflowed".into()))?;
630    if end > bytes.len() {
631        return Err(Error::InvalidInput(
632            "RTF binary payload is truncated".into(),
633        ));
634    }
635    *index = end;
636    Ok(())
637}
638
639fn read_rtf_picture(
640    bytes: &[u8],
641    start: usize,
642    allow_capture: bool,
643) -> Result<(usize, RtfPictureCapture, usize)> {
644    let mut index = start;
645    let mut nested_depth = 0usize;
646    let mut capture = RtfPictureCapture::default();
647    let mut guid_hex_remaining = 0usize;
648    let mut token_count = 0usize;
649    while index < bytes.len() {
650        token_count = token_count.saturating_add(1);
651        if token_count > MAX_RTF_TOKENS {
652            return Err(Error::LimitExceeded(format!(
653                "RTF picture token scan exceeds {MAX_RTF_TOKENS}"
654            )));
655        }
656        match bytes[index] {
657            b'}' if nested_depth == 0 => {
658                if capture.pending_nibble.is_some() {
659                    capture.malformed = true;
660                }
661                return Ok((
662                    index,
663                    capture,
664                    index.saturating_add(1).saturating_sub(start),
665                ));
666            }
667            b'}' => {
668                nested_depth = nested_depth.saturating_sub(1);
669                index += 1;
670            }
671            b'{' => {
672                nested_depth = nested_depth.saturating_add(1);
673                if nested_depth > MAX_RTF_GROUP_DEPTH {
674                    return Err(Error::LimitExceeded(format!(
675                        "RTF picture group depth exceeds {MAX_RTF_GROUP_DEPTH}"
676                    )));
677                }
678                index += 1;
679            }
680            b'\\' => {
681                index += 1;
682                let Some(&next) = bytes.get(index) else {
683                    return Err(Error::InvalidInput(
684                        "RTF picture ends after a backslash".into(),
685                    ));
686                };
687                if next.is_ascii_alphabetic() {
688                    let word_start = index;
689                    while bytes.get(index).is_some_and(u8::is_ascii_alphabetic) {
690                        index += 1;
691                    }
692                    let word = std::str::from_utf8(&bytes[word_start..index])
693                        .map_err(|_| {
694                            Error::InvalidInput("RTF picture control word is not ASCII".into())
695                        })?
696                        .to_ascii_lowercase();
697                    let parameter_start = index;
698                    if bytes.get(index) == Some(&b'-') {
699                        index += 1;
700                    }
701                    while bytes.get(index).is_some_and(u8::is_ascii_digit) {
702                        index += 1;
703                    }
704                    let parameter = if index > parameter_start {
705                        std::str::from_utf8(&bytes[parameter_start..index])
706                            .ok()
707                            .and_then(|value| value.parse::<i32>().ok())
708                    } else {
709                        None
710                    };
711                    if bytes.get(index) == Some(&b' ') {
712                        index += 1;
713                    }
714                    if word == "bin" {
715                        let length = usize::try_from(parameter.ok_or_else(|| {
716                            Error::InvalidInput(
717                                "RTF picture binary payload is missing its byte count".into(),
718                            )
719                        })?)
720                        .map_err(|_| {
721                            Error::InvalidInput(
722                                "RTF picture binary payload length must be non-negative".into(),
723                            )
724                        })?;
725                        if length > MAX_RTF_BINARY_SKIP {
726                            return Err(Error::LimitExceeded(format!(
727                                "RTF binary payload exceeds {MAX_RTF_BINARY_SKIP} bytes"
728                            )));
729                        }
730                        let end = index.checked_add(length).ok_or_else(|| {
731                            Error::LimitExceeded(
732                                "RTF picture binary payload length overflowed".into(),
733                            )
734                        })?;
735                        let binary = bytes.get(index..end).ok_or_else(|| {
736                            Error::InvalidInput("RTF picture binary payload is truncated".into())
737                        })?;
738                        if nested_depth == 0 && capture.mime.is_some() {
739                            if capture.pending_nibble.take().is_some() {
740                                capture.malformed = true;
741                            }
742                            append_rtf_picture_data(&mut capture, binary, allow_capture);
743                        }
744                        index = end;
745                    } else if nested_depth == 0 {
746                        match word.as_str() {
747                            "pngblip" => capture.mime = Some("image/png"),
748                            "jpegblip" | "jpgblip" => capture.mime = Some("image/jpeg"),
749                            "blipuid" => guid_hex_remaining = 32,
750                            "picwgoal" => {
751                                capture.width_goal_twips =
752                                    parse_rtf_positive_u32(parameter, 100_000_000);
753                                capture.invalid_dimensions |= capture.width_goal_twips.is_none();
754                            }
755                            "pichgoal" => {
756                                capture.height_goal_twips =
757                                    parse_rtf_positive_u32(parameter, 100_000_000);
758                                capture.invalid_dimensions |= capture.height_goal_twips.is_none();
759                            }
760                            "picscalex" => {
761                                capture.scale_x_percent =
762                                    parse_rtf_positive_u32(parameter, MAX_RTF_SCALE_PERCENT);
763                                capture.invalid_dimensions |= capture.scale_x_percent.is_none();
764                            }
765                            "picscaley" => {
766                                capture.scale_y_percent =
767                                    parse_rtf_positive_u32(parameter, MAX_RTF_SCALE_PERCENT);
768                                capture.invalid_dimensions |= capture.scale_y_percent.is_none();
769                            }
770                            _ => {}
771                        }
772                    }
773                } else if next == b'\'' {
774                    index += 1;
775                    let pair = bytes.get(index..index.saturating_add(2)).ok_or_else(|| {
776                        Error::InvalidInput("RTF picture hexadecimal escape is truncated".into())
777                    })?;
778                    let hex = std::str::from_utf8(pair).map_err(|_| {
779                        Error::InvalidInput("RTF picture hexadecimal escape is not ASCII".into())
780                    })?;
781                    let value = u8::from_str_radix(hex, 16).map_err(|_| {
782                        Error::InvalidInput("RTF picture hexadecimal escape is invalid".into())
783                    })?;
784                    if nested_depth == 0 && capture.mime.is_some() {
785                        append_rtf_picture_data(&mut capture, &[value], allow_capture);
786                    }
787                    index += 2;
788                } else {
789                    index += 1;
790                }
791            }
792            byte if nested_depth == 0 && capture.mime.is_some() => {
793                index += 1;
794                if byte.is_ascii_whitespace() {
795                    continue;
796                }
797                if guid_hex_remaining > 0 {
798                    if byte.is_ascii_hexdigit() {
799                        guid_hex_remaining -= 1;
800                    } else {
801                        guid_hex_remaining = 0;
802                        capture.malformed = true;
803                    }
804                    continue;
805                }
806                let Some(nibble) = rtf_hex_nibble(byte) else {
807                    capture.malformed = true;
808                    continue;
809                };
810                if let Some(high) = capture.pending_nibble.take() {
811                    append_rtf_picture_data(&mut capture, &[(high << 4) | nibble], allow_capture);
812                } else {
813                    capture.pending_nibble = Some(nibble);
814                }
815            }
816            _ => index += 1,
817        }
818    }
819    Err(Error::InvalidInput(
820        "RTF picture destination is incomplete".into(),
821    ))
822}
823
824fn rtf_hex_nibble(byte: u8) -> Option<u8> {
825    match byte {
826        b'0'..=b'9' => Some(byte - b'0'),
827        b'a'..=b'f' => Some(byte - b'a' + 10),
828        b'A'..=b'F' => Some(byte - b'A' + 10),
829        _ => None,
830    }
831}
832
833fn parse_rtf_positive_u32(value: Option<i32>, maximum: u32) -> Option<u32> {
834    let value = u32::try_from(value?).ok()?;
835    (value > 0 && value <= maximum).then_some(value)
836}
837
838fn rtf_display_dimensions(
839    picture: &RtfPictureCapture,
840    pixel_width: u32,
841    pixel_height: u32,
842) -> Option<(u32, u32)> {
843    if pixel_width == 0 || pixel_height == 0 {
844        return None;
845    }
846    let aspect = f64::from(pixel_width) / f64::from(pixel_height);
847    let (base_width, base_height) = match (picture.width_goal_twips, picture.height_goal_twips) {
848        (Some(width), Some(height)) => (f64::from(width) / 20.0, f64::from(height) / 20.0),
849        (Some(width), None) => {
850            let width = f64::from(width) / 20.0;
851            (width, width / aspect)
852        }
853        (None, Some(height)) => {
854            let height = f64::from(height) / 20.0;
855            (height * aspect, height)
856        }
857        (None, None) => (f64::from(pixel_width), f64::from(pixel_height)),
858    };
859    let width = base_width * f64::from(picture.scale_x_percent.unwrap_or(100)) / 100.0;
860    let height = base_height * f64::from(picture.scale_y_percent.unwrap_or(100)) / 100.0;
861    if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
862        return None;
863    }
864    let clamp_scale = (MAX_RTF_DISPLAY_SIDE / width.max(height)).min(1.0);
865    Some((
866        (width * clamp_scale).round().max(1.0) as u32,
867        (height * clamp_scale).round().max(1.0) as u32,
868    ))
869}
870
871fn append_rtf_picture_data(capture: &mut RtfPictureCapture, bytes: &[u8], allow_capture: bool) {
872    capture.decoded_byte_count = capture.decoded_byte_count.saturating_add(bytes.len());
873    if capture.decoded_byte_count > MAX_RTF_IMAGE_BYTES {
874        capture.exceeded_limit = true;
875        return;
876    }
877    if allow_capture {
878        capture.bytes.extend_from_slice(bytes);
879    }
880}
881
882fn attach_rtf_picture(
883    picture: RtfPictureCapture,
884    budget: &mut RtfImageBudget,
885    warnings: &mut Vec<String>,
886    blocks: &mut Vec<HtmlBlock>,
887) -> Result<()> {
888    if picture.exceeded_limit {
889        push_rtf_warning_once(
890            warnings,
891            "RTF picture exceeded the per-image byte limit and was omitted",
892        );
893        return Ok(());
894    }
895    if picture.malformed || picture.pending_nibble.is_some() {
896        push_rtf_warning_once(warnings, "malformed RTF picture data was omitted");
897        return Ok(());
898    }
899    let Some(declared_mime) = picture.mime else {
900        push_rtf_warning_once(
901            warnings,
902            "unsupported RTF picture types were omitted; only PNG and JPEG are embedded",
903        );
904        return Ok(());
905    };
906    let Some(mime) =
907        sniff_image_mime(&picture.bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
908    else {
909        push_rtf_warning_once(warnings, "invalid RTF PNG/JPEG picture data was omitted");
910        return Ok(());
911    };
912    if mime != declared_mime {
913        push_rtf_warning_once(
914            warnings,
915            "RTF picture type marker did not match its image signature; the data signature was used",
916        );
917    }
918    let Some((width, height)) = crate::document::mhtml::image_dimensions(&picture.bytes, mime)
919    else {
920        push_rtf_warning_once(
921            warnings,
922            "invalid RTF PNG/JPEG picture dimensions were omitted",
923        );
924        return Ok(());
925    };
926    if picture.invalid_dimensions {
927        push_rtf_warning_once(
928            warnings,
929            "RTF picture goal size or scale was invalid and was approximated from the image pixels",
930        );
931    }
932    let (display_width, display_height) =
933        rtf_display_dimensions(&picture, width, height).unwrap_or((width, height));
934    let pixels = u64::from(width) * u64::from(height);
935    let next_pixels = budget.pixels.saturating_add(pixels);
936    if width == 0
937        || height == 0
938        || pixels > MAX_RTF_IMAGE_PIXELS
939        || next_pixels > MAX_RTF_TOTAL_IMAGE_PIXELS
940    {
941        push_rtf_warning_once(
942            warnings,
943            "RTF pictures exceeded the per-image or total pixel limit and were omitted",
944        );
945        return Ok(());
946    }
947    let next_image_bytes = budget.image_bytes.saturating_add(picture.bytes.len());
948    if next_image_bytes > MAX_RTF_TOTAL_IMAGE_BYTES {
949        push_rtf_warning_once(
950            warnings,
951            "RTF pictures exceeded the total image byte limit and were omitted",
952        );
953        return Ok(());
954    }
955    let prefix = format!("data:{mime};base64,");
956    let uri_bytes = prefix
957        .len()
958        .saturating_add(picture.bytes.len().div_ceil(3).saturating_mul(4));
959    let next_uri_bytes = budget.data_uri_bytes.saturating_add(uri_bytes);
960    if next_uri_bytes > MAX_RTF_TOTAL_DATA_URI_BYTES {
961        push_rtf_warning_once(
962            warnings,
963            "RTF pictures exceeded the total data URI byte limit and were omitted",
964        );
965        return Ok(());
966    }
967    blocks.push(HtmlBlock::Image {
968        href: format!("{prefix}{}", BASE64_STANDARD.encode(&picture.bytes)),
969        pixel_width: display_width,
970        pixel_height: display_height,
971        alt: "Embedded RTF picture".into(),
972    });
973    budget.image_bytes = next_image_bytes;
974    budget.data_uri_bytes = next_uri_bytes;
975    budget.pixels = next_pixels;
976    Ok(())
977}
978
979fn push_rtf_warning_once(warnings: &mut Vec<String>, warning: &str) {
980    if !warnings.iter().any(|existing| existing == warning) {
981        warnings.push(warning.to_owned());
982    }
983}
984
985fn encoding_for_codepage(codepage: u16) -> Option<&'static Encoding> {
986    let label: &'static [u8] = match codepage {
987        874 => b"windows-874",
988        932 => b"shift_jis",
989        936 => b"gbk",
990        949 => b"euc-kr",
991        950 => b"big5",
992        1250 => b"windows-1250",
993        1251 => b"windows-1251",
994        1252 => b"windows-1252",
995        1253 => b"windows-1253",
996        1254 => b"windows-1254",
997        1255 => b"windows-1255",
998        1256 => b"windows-1256",
999        1257 => b"windows-1257",
1000        1258 => b"windows-1258",
1001        10000 => b"macintosh",
1002        28591 => b"iso-8859-1",
1003        28592 => b"iso-8859-2",
1004        28597 => b"iso-8859-7",
1005        28605 => b"iso-8859-15",
1006        65001 => b"utf-8",
1007        _ => return None,
1008    };
1009    Encoding::for_label(label)
1010}
1011
1012fn buffer_ansi_byte(buffer: &mut Vec<u8>, byte: u8, output_bytes: usize) -> Result<()> {
1013    if output_bytes.saturating_add(buffer.len()).saturating_add(1) > MAX_RTF_TEXT_BYTES {
1014        return Err(Error::LimitExceeded(format!(
1015            "RTF extracted text exceeds {MAX_RTF_TEXT_BYTES} bytes"
1016        )));
1017    }
1018    buffer.push(byte);
1019    Ok(())
1020}
1021
1022fn flush_ansi_buffer(
1023    buffer: &mut Vec<u8>,
1024    codepage: u16,
1025    output: &mut String,
1026    output_bytes: &mut usize,
1027    warnings: &mut Vec<String>,
1028    warned_invalid_encoding: &mut bool,
1029) -> Result<()> {
1030    if buffer.is_empty() {
1031        return Ok(());
1032    }
1033    let encoding = encoding_for_codepage(codepage).unwrap_or(encoding_rs::WINDOWS_1252);
1034    let (decoded, had_errors) = encoding.decode_without_bom_handling(buffer);
1035    if had_errors && !*warned_invalid_encoding {
1036        warnings.push(
1037            "RTF contains invalid or incomplete ANSI byte sequences; replacement characters were inserted".into(),
1038        );
1039        *warned_invalid_encoding = true;
1040    }
1041    append_text(output, &decoded, output_bytes)?;
1042    buffer.clear();
1043    Ok(())
1044}
1045
1046fn append_utf16_unit(
1047    unit: u16,
1048    pending: &mut Option<u16>,
1049    output: &mut String,
1050    output_bytes: &mut usize,
1051) -> Result<()> {
1052    if (0xd800..=0xdbff).contains(&unit) {
1053        flush_pending_surrogate(pending, output, output_bytes)?;
1054        *pending = Some(unit);
1055        return Ok(());
1056    }
1057    if (0xdc00..=0xdfff).contains(&unit) {
1058        if let Some(high) = pending.take() {
1059            let scalar = 0x1_0000 + (((high as u32 - 0xd800) << 10) | (unit as u32 - 0xdc00));
1060            let character = char::from_u32(scalar)
1061                .ok_or_else(|| Error::InvalidInput("invalid RTF surrogate pair".into()))?;
1062            return append_char(output, character, output_bytes);
1063        }
1064        return append_char(output, '\u{fffd}', output_bytes);
1065    }
1066    flush_pending_surrogate(pending, output, output_bytes)?;
1067    let character = char::from_u32(unit as u32)
1068        .ok_or_else(|| Error::InvalidInput("invalid RTF Unicode value".into()))?;
1069    append_char(output, character, output_bytes)
1070}
1071
1072fn flush_pending_surrogate(
1073    pending: &mut Option<u16>,
1074    output: &mut String,
1075    output_bytes: &mut usize,
1076) -> Result<()> {
1077    if pending.take().is_some() {
1078        append_char(output, '\u{fffd}', output_bytes)?;
1079    }
1080    Ok(())
1081}
1082
1083fn append_char(output: &mut String, character: char, total_bytes: &mut usize) -> Result<()> {
1084    let scalar = character as u32;
1085    if matches!(scalar, 0..=8 | 11..=12 | 14..=31) {
1086        return Err(Error::InvalidInput(
1087            "RTF text contains an XML-disallowed control character".into(),
1088        ));
1089    }
1090    *total_bytes = (*total_bytes).saturating_add(character.len_utf8());
1091    if *total_bytes > MAX_RTF_TEXT_BYTES {
1092        return Err(Error::LimitExceeded(format!(
1093            "RTF extracted text exceeds {MAX_RTF_TEXT_BYTES} bytes"
1094        )));
1095    }
1096    output.push(character);
1097    Ok(())
1098}
1099
1100fn append_text(output: &mut String, text: &str, total_bytes: &mut usize) -> Result<()> {
1101    *total_bytes = (*total_bytes).saturating_add(text.len());
1102    if *total_bytes > MAX_RTF_TEXT_BYTES {
1103        return Err(Error::LimitExceeded(format!(
1104            "RTF extracted text exceeds {MAX_RTF_TEXT_BYTES} bytes"
1105        )));
1106    }
1107    output.push_str(text);
1108    Ok(())
1109}
1110
1111fn flush_paragraph(paragraph: &mut String, blocks: &mut Vec<HtmlBlock>) -> Result<()> {
1112    let text = paragraph.trim().to_owned();
1113    paragraph.clear();
1114    if !text.is_empty() {
1115        blocks.push(HtmlBlock::Paragraph { text });
1116        check_block_limit(blocks)?;
1117    }
1118    Ok(())
1119}
1120
1121fn check_block_limit(blocks: &[HtmlBlock]) -> Result<()> {
1122    if blocks.len() > MAX_RTF_BLOCKS {
1123        return Err(Error::LimitExceeded(format!(
1124            "RTF paragraph/block count exceeds {MAX_RTF_BLOCKS}"
1125        )));
1126    }
1127    Ok(())
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    #[test]
1135    fn extracts_rtf_paragraphs_windows_1252_unicode_and_surrogate_pairs() {
1136        let rtf = br#"{\rtf1\ansi\ansicpg1252\uc1
1137{\fonttbl{\f0 Arial;}}
1138Hello \b bold\b0\par
1139Caf\'e9 \u8217? smile \u-10179?\u-8701?\par
1140{\pict\pngblip 89504e470d0a}After
1141}"#;
1142        let (blocks, warnings) = parse_rtf(rtf).unwrap();
1143        let paragraphs = blocks
1144            .iter()
1145            .filter_map(|block| match block {
1146                HtmlBlock::Paragraph { text } => Some(text.as_str()),
1147                _ => None,
1148            })
1149            .collect::<Vec<_>>();
1150        assert_eq!(paragraphs, ["Hello bold", "Café ’ smile 😃", "After"]);
1151        assert!(
1152            warnings
1153                .iter()
1154                .any(|warning| warning.contains("invalid RTF PNG/JPEG picture data"))
1155        );
1156        assert!(
1157            warnings
1158                .iter()
1159                .any(|warning| warning.contains("picture anchors"))
1160        );
1161        assert!(
1162            warnings
1163                .iter()
1164                .any(|warning| warning.contains("styling is flattened"))
1165        );
1166    }
1167
1168    #[test]
1169    fn decodes_multibyte_rtf_ansi_codepages() {
1170        let (blocks, warnings) = parse_rtf(br"{\rtf1\ansi\ansicpg932\uc1 \'82\'a0\par}").unwrap();
1171        assert!(matches!(&blocks[0], HtmlBlock::Paragraph { text } if text == "あ"));
1172        assert!(
1173            !warnings
1174                .iter()
1175                .any(|warning| warning.contains("code page 932 is unsupported"))
1176        );
1177    }
1178
1179    #[test]
1180    fn skips_unsupported_rtf_binary_picture_and_rejects_incomplete_input() {
1181        let mut rtf = b"{\\rtf1 Before {\\pict\\bin4 ".to_vec();
1182        rtf.extend_from_slice(&[b'{', b'}', b'\\', 0]);
1183        rtf.extend_from_slice(b"} After\\par}");
1184        let (blocks, warnings) = parse_rtf(&rtf).unwrap();
1185        assert!(matches!(&blocks[0], HtmlBlock::Paragraph { text } if text == "Before"));
1186        assert!(matches!(&blocks[1], HtmlBlock::Paragraph { text } if text == "After"));
1187        assert!(
1188            warnings
1189                .iter()
1190                .any(|warning| warning.contains("unsupported RTF picture types"))
1191        );
1192
1193        assert!(matches!(
1194            parse_rtf(b"{\\rtf1{\\pict\\bin4 ab"),
1195            Err(Error::InvalidInput(_))
1196        ));
1197        assert!(matches!(
1198            parse_rtf(b"{\\rtf1 missing close"),
1199            Err(Error::InvalidInput(_))
1200        ));
1201    }
1202
1203    #[test]
1204    fn embeds_hex_encoded_png_between_surrounding_rtf_text() {
1205        let png = sample_png();
1206        let hex = png
1207            .iter()
1208            .map(|byte| format!("{byte:02x}"))
1209            .collect::<String>();
1210        let rtf = format!(
1211            r#"{{\rtf1 Before {{\pict\pngblip\picw2\pich1\picwgoal1280\pichgoal320 {hex}}} After\par}}"#
1212        );
1213        let (blocks, warnings) = parse_rtf(rtf.as_bytes()).unwrap();
1214
1215        assert!(matches!(&blocks[0], HtmlBlock::Paragraph { text } if text == "Before"));
1216        assert!(
1217            matches!(&blocks[1], HtmlBlock::Image { href, pixel_width: 64, pixel_height: 16, alt } if href.starts_with("data:image/png;base64,") && alt == "Embedded RTF picture")
1218        );
1219        assert!(matches!(&blocks[2], HtmlBlock::Paragraph { text } if text == "After"));
1220        assert!(
1221            warnings
1222                .iter()
1223                .any(|warning| warning.contains("picture anchors"))
1224        );
1225        assert!(
1226            !warnings
1227                .iter()
1228                .any(|warning| warning.contains("invalid RTF PNG"))
1229        );
1230    }
1231
1232    #[test]
1233    fn binary_picture_data_can_contain_rtf_delimiter_bytes() {
1234        let png = sample_png();
1235        let mut rtf = format!("{{\\rtf1 Before {{\\pict\\pngblip\\bin{} ", png.len()).into_bytes();
1236        rtf.extend_from_slice(&png);
1237        rtf.extend_from_slice(b"} After\\par}");
1238        let (blocks, _) = parse_rtf(&rtf).unwrap();
1239
1240        assert!(matches!(&blocks[0], HtmlBlock::Paragraph { text } if text == "Before"));
1241        assert!(
1242            matches!(&blocks[1], HtmlBlock::Image { href, pixel_width: 2, pixel_height: 1, .. } if href.starts_with("data:image/png;base64,"))
1243        );
1244        assert!(matches!(&blocks[2], HtmlBlock::Paragraph { text } if text == "After"));
1245    }
1246
1247    #[test]
1248    fn preserves_explicit_rtf_page_breaks_for_the_page_composer() {
1249        let (blocks, _) = parse_rtf(br"{\rtf1 First page\par\page Second page}").unwrap();
1250        assert!(matches!(&blocks[0], HtmlBlock::Paragraph { text } if text == "First page"));
1251        assert!(matches!(&blocks[1], HtmlBlock::PageBreak));
1252        assert!(matches!(&blocks[2], HtmlBlock::Paragraph { text } if text == "Second page"));
1253    }
1254
1255    #[test]
1256    fn enforces_rtf_group_depth() {
1257        let mut rtf = b"{\\rtf1".to_vec();
1258        rtf.extend(std::iter::repeat_n(b'{', MAX_RTF_GROUP_DEPTH + 1));
1259        rtf.extend(std::iter::repeat_n(b'}', MAX_RTF_GROUP_DEPTH + 2));
1260        assert!(matches!(parse_rtf(&rtf), Err(Error::LimitExceeded(_))));
1261    }
1262
1263    fn sample_png() -> Vec<u8> {
1264        let mut png = Vec::new();
1265        {
1266            let mut encoder = png::Encoder::new(&mut png, 2, 1);
1267            encoder.set_color(png::ColorType::Rgb);
1268            encoder.set_depth(png::BitDepth::Eight);
1269            let mut writer = encoder.write_header().unwrap();
1270            writer.write_image_data(&[255, 0, 0, 0, 0, 255]).unwrap();
1271        }
1272        png
1273    }
1274}