1use 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 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}