mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
craft-rtf: Outlook compressed RTF and body text, from MailCraft (#961)
The outlook feature adds MailCraft's reader of PidTagRtfCompressed ([MS-OXRTFCP]) and of the text and encapsulated HTML ([MS-OXRTFEX]) of the RTF inside, from crates/pst/src/rtf.rs at a44a450. Font charsets use this crate's charset_code_page table; code pages are decoded by craft-mime. Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
co-authored by
Martin Ball
parent
219f63f64c
commit
53e1717523
Generated
+2
@@ -1556,7 +1556,9 @@ dependencies = [
|
||||
name = "craft-rtf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-mime",
|
||||
"encoding_rs",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -10,6 +10,16 @@ repository.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# `outlook`: compressed RTF ([MS-OXRTFCP]) and the text or encapsulated HTML ([MS-OXRTFEX]) of a
|
||||
# mail item's body.
|
||||
outlook = ["dep:craft-mime"]
|
||||
|
||||
[dependencies]
|
||||
# Character sets by Windows code page number, for the `outlook` module.
|
||||
craft-mime = { workspace = true, optional = true }
|
||||
# The East Asian multibyte code pages (932, 936, 949, 950).
|
||||
encoding_rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -79,6 +79,17 @@ elsewhere are kept. `\sect` ends a paragraph.
|
||||
The reader does not recurse, and what it returns is never larger than a small multiple of the
|
||||
file: each character of text costs the file at least one byte.
|
||||
|
||||
## Outlook bodies (feature `outlook`)
|
||||
|
||||
`outlook::decompress_rtf` undoes the compression of `PidTagRtfCompressed` ([MS-OXRTFCP]: `LZFu`
|
||||
with its CRC checked, or `MELA` stored as is), never allocating past the caller's cap.
|
||||
`outlook::rtf_text` gives the text a mail body shows (font charsets and `\ansicpg` decoded through
|
||||
`craft_mime::charset`, `\uN` with its fallback skipped, destinations dropped), and
|
||||
`outlook::rtf_html` the HTML of an encapsulated-HTML body ([MS-OXRTFEX], `\fromhtml1`). Groups nest
|
||||
at most `MAX_RTF_DEPTH` (256) deep. From MailCraft `crates/pst/src/rtf.rs` at
|
||||
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
|
||||
by Martin Ball and the MailCraft contributors.
|
||||
|
||||
## Not here yet
|
||||
|
||||
WordCraft's full reader and writer (styles, lists, fields, bookmarks, pictures, notes,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub mod codepage;
|
||||
mod date;
|
||||
mod lex;
|
||||
mod model;
|
||||
#[cfg(feature = "outlook")]
|
||||
pub mod outlook;
|
||||
mod read;
|
||||
mod text;
|
||||
mod write;
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
//! Rich text as Outlook keeps it in `PidTagRtfCompressed` (0x1009), in .pst and .msg files and
|
||||
//! TNEF attachments: the compression of
|
||||
//! [MS-OXRTFCP] ([`decompress_rtf`]) and the text of the RTF that comes out ([`rtf_text`]),
|
||||
//! written from those two specifications and the public Rich Text Format specification.
|
||||
//!
|
||||
//! Both read hostile bytes: output is capped by the caller, a header's size is never trusted
|
||||
//! for an allocation past that cap, nothing recurses (groups are counted, not nested calls),
|
||||
//! and every loop ends with its input.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_mime::charset::{code_page_label, decode};
|
||||
|
||||
use crate::codepage;
|
||||
|
||||
/// Why rich text was not read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The bytes are not what the format says; the text names what was wrong.
|
||||
Corrupt(&'static str),
|
||||
/// The compressed bytes do not have the CRC their header gives.
|
||||
BadChecksum,
|
||||
/// More would come out than the caller's cap allows.
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Corrupt(what) => f.write_str(what),
|
||||
Error::BadChecksum => f.write_str("compressed rich text with a wrong checksum"),
|
||||
Error::TooLarge => f.write_str("rich text larger than allowed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// The CRC of [MS-OXRTFCP] 2.1.3.1 and 3.2: the reflected CRC-32 polynomial, seeded with zero and
|
||||
/// with no final inversion.
|
||||
pub(crate) fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0u32;
|
||||
for &b in data {
|
||||
crc ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// The dictionary a stream starts with ([MS-OXRTFCP] 2.1.2.1), 207 bytes.
|
||||
const DICT_INIT: &[u8] = b"{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx";
|
||||
const _: () = assert!(DICT_INIT.len() == 207);
|
||||
const DICT_SIZE: usize = 4096;
|
||||
const HEADER: usize = 16;
|
||||
const LZFU: u32 = 0x7546_5A4C;
|
||||
const MELA: u32 = 0x414C_454D;
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes(<[u8; 4]>::try_from(s).ok()?))
|
||||
}
|
||||
|
||||
/// The RTF inside `PidTagRtfCompressed` bytes: the 16-byte header, then either the text as it
|
||||
/// is (`MELA`) or the dictionary-compressed runs (`LZFu`) whose CRC is checked. At most
|
||||
/// `max_out` bytes come out; past that is [`Error::TooLarge`], never a bigger allocation.
|
||||
pub fn decompress_rtf(data: &[u8], max_out: usize) -> Result<Vec<u8>> {
|
||||
let (Some(comp_size), Some(raw_size), Some(kind), Some(crc)) = (le32(data, 0), le32(data, 4), le32(data, 8), le32(data, 12)) else {
|
||||
return Err(Error::Corrupt("rich text shorter than its header"));
|
||||
};
|
||||
let raw_size = usize::try_from(raw_size).unwrap_or(usize::MAX);
|
||||
let body = data.get(HEADER..).unwrap_or_default();
|
||||
match kind {
|
||||
MELA => {
|
||||
// The reader may take the sizes the header gives; never more than is there.
|
||||
let take = raw_size.min(body.len());
|
||||
if take > max_out {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
Ok(body.get(..take).unwrap_or_default().to_vec())
|
||||
}
|
||||
LZFU => {
|
||||
if raw_size > max_out {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
// COMPSIZE counts the 12 bytes of the header after itself.
|
||||
let content_len = usize::try_from(comp_size)
|
||||
.unwrap_or(usize::MAX)
|
||||
.checked_sub(12)
|
||||
.ok_or(Error::Corrupt("rich text with a size smaller than its header"))?;
|
||||
let content = body.get(..content_len).ok_or(Error::Corrupt("rich text cut short"))?;
|
||||
let out = lzfu(content, max_out, raw_size)?;
|
||||
if crc32(content) != crc {
|
||||
return Err(Error::BadChecksum);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
_ => Err(Error::Corrupt("rich text of a kind not known")),
|
||||
}
|
||||
}
|
||||
|
||||
fn lzfu(content: &[u8], max_out: usize, raw_size: usize) -> Result<Vec<u8>> {
|
||||
let mut dict = vec![0u8; DICT_SIZE];
|
||||
if let Some(head) = dict.get_mut(..DICT_INIT.len()) {
|
||||
head.copy_from_slice(DICT_INIT);
|
||||
}
|
||||
let mut write = DICT_INIT.len();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(raw_size.min(max_out).min(1 << 20));
|
||||
let mut at = 0usize;
|
||||
let cut = Error::Corrupt("compressed rich text ends before its end mark");
|
||||
loop {
|
||||
let control = *content.get(at).ok_or(cut)?;
|
||||
at = at.saturating_add(1);
|
||||
for bit in 0..8u32 {
|
||||
if control >> bit & 1 == 0 {
|
||||
let b = *content.get(at).ok_or(cut)?;
|
||||
at = at.saturating_add(1);
|
||||
if out.len() >= max_out {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
out.push(b);
|
||||
if let Some(d) = dict.get_mut(write) {
|
||||
*d = b;
|
||||
}
|
||||
write = write.saturating_add(1) % DICT_SIZE;
|
||||
} else {
|
||||
let (Some(&hi), Some(&lo)) = (content.get(at), content.get(at.saturating_add(1))) else { return Err(cut) };
|
||||
at = at.saturating_add(2);
|
||||
let word = usize::from(hi) << 8 | usize::from(lo);
|
||||
let mut read = word >> 4;
|
||||
if read == write {
|
||||
return Ok(out);
|
||||
}
|
||||
for _ in 0..(word & 0xF).saturating_add(2) {
|
||||
let b = dict.get(read).copied().unwrap_or(0);
|
||||
if out.len() >= max_out {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
out.push(b);
|
||||
read = read.saturating_add(1) % DICT_SIZE;
|
||||
if let Some(d) = dict.get_mut(write) {
|
||||
*d = b;
|
||||
}
|
||||
write = write.saturating_add(1) % DICT_SIZE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest nesting of groups read.
|
||||
pub const MAX_RTF_DEPTH: usize = 256;
|
||||
|
||||
/// Destinations whose content is not the document's text.
|
||||
const SKIPPED: &[&str] = &[
|
||||
"fonttbl",
|
||||
"colortbl",
|
||||
"stylesheet",
|
||||
"info",
|
||||
"pict",
|
||||
"object",
|
||||
"header",
|
||||
"headerl",
|
||||
"headerr",
|
||||
"headerf",
|
||||
"footer",
|
||||
"footerl",
|
||||
"footerr",
|
||||
"footerf",
|
||||
"themedata",
|
||||
"colorschememapping",
|
||||
"latentstyles",
|
||||
"datastore",
|
||||
"listtable",
|
||||
"listoverridetable",
|
||||
"revtbl",
|
||||
"rsidtbl",
|
||||
"generator",
|
||||
"filetbl",
|
||||
"fldinst",
|
||||
"xmlnstbl",
|
||||
"mmathpr",
|
||||
"pgdsctbl",
|
||||
"template",
|
||||
"private",
|
||||
"bkmkstart",
|
||||
"bkmkend",
|
||||
"wgrffmtfilter",
|
||||
"shpinst",
|
||||
"nonshppict",
|
||||
"pnseclvl",
|
||||
"listtext",
|
||||
"blipuid",
|
||||
"falt",
|
||||
"panose",
|
||||
"fchars",
|
||||
"lchars",
|
||||
"xe",
|
||||
"tc",
|
||||
"tcn",
|
||||
"ud",
|
||||
"upr",
|
||||
"title",
|
||||
"subject",
|
||||
"author",
|
||||
"operator",
|
||||
"company",
|
||||
"keywords",
|
||||
"doccomm",
|
||||
"comment",
|
||||
"category",
|
||||
"manager",
|
||||
"hlinkbase",
|
||||
"buptim",
|
||||
"creatim",
|
||||
"revtim",
|
||||
"printim",
|
||||
"txe",
|
||||
"rxe",
|
||||
"pnseclvl",
|
||||
"protusertbl",
|
||||
"defchp",
|
||||
"defpap",
|
||||
"fontemb",
|
||||
"fontfile",
|
||||
"background",
|
||||
"docvar",
|
||||
"userprops",
|
||||
"mhtmltag",
|
||||
"htmltag",
|
||||
"factoidname",
|
||||
"oleclsid",
|
||||
"objdata",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Group {
|
||||
/// Skip characters after `\u` (`\ucN`).
|
||||
uc: u32,
|
||||
skip: bool,
|
||||
/// In the font table: `\fN` names the font that `\fcharsetN` is for.
|
||||
fonttbl: bool,
|
||||
/// The font being defined (in the font table).
|
||||
defining: i64,
|
||||
/// The font text is in (`\fN`), if one was chosen.
|
||||
font: Option<i64>,
|
||||
/// Encapsulated HTML: under `\htmlrtf`, what isn't part of the HTML.
|
||||
suppressed: bool,
|
||||
/// Encapsulated HTML: inside an `\htmltag` group.
|
||||
in_tag: bool,
|
||||
}
|
||||
|
||||
impl Group {
|
||||
fn hidden(&self) -> bool {
|
||||
self.skip || self.suppressed
|
||||
}
|
||||
}
|
||||
|
||||
/// Most fonts whose character set is kept.
|
||||
const MAX_FONTS: usize = 1024;
|
||||
/// Most bytes of `\'hh` kept waiting for a character boundary.
|
||||
const PENDING: usize = 4096;
|
||||
|
||||
struct Out {
|
||||
text: String,
|
||||
max: usize,
|
||||
/// Text was dropped for the cap.
|
||||
cut: bool,
|
||||
pending: Vec<u8>,
|
||||
/// The code page of `pending`.
|
||||
pending_cp: u32,
|
||||
high: Option<u16>,
|
||||
ansicpg: Option<u32>,
|
||||
mac: bool,
|
||||
deff: Option<i64>,
|
||||
/// `\fcharset` of each font in the font table.
|
||||
fonts: HashMap<i64, i64>,
|
||||
/// Reading encapsulated HTML rather than the text a document shows.
|
||||
html: bool,
|
||||
}
|
||||
|
||||
/// The code page a font character set stands for ([MSFT-RTF] `\fcharsetN`): `None` for the
|
||||
/// document's own (0 is Windows ANSI, 1252, which is not the document's when it says
|
||||
/// `\ansicpg` otherwise: [MS-OXRTFEX] 3.1 marks one font `\fcharset0` in a 1251 document to
|
||||
/// have 0xA8 read as U+00A8). Symbol (2), Johab (130) and the OEM sets (254, 255) are read in the document's.
|
||||
fn charset_code_page(charset: i64) -> Option<u32> {
|
||||
match charset {
|
||||
0 => Some(1252),
|
||||
2 | 254 | 255 => None,
|
||||
other => i32::try_from(other).ok().and_then(codepage::charset_code_page).map(u32::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b` begins a two-byte character in code page `cp`.
|
||||
fn is_lead(cp: u32, b: u8) -> bool {
|
||||
match cp {
|
||||
932 => matches!(b, 0x81..=0x9F | 0xE0..=0xFC),
|
||||
936 | 949 | 950 => matches!(b, 0x81..=0xFE),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn new(max: usize) -> Out {
|
||||
Out {
|
||||
text: String::new(),
|
||||
max,
|
||||
cut: false,
|
||||
pending: Vec::new(),
|
||||
pending_cp: 1252,
|
||||
high: None,
|
||||
ansicpg: None,
|
||||
mac: false,
|
||||
deff: None,
|
||||
fonts: HashMap::new(),
|
||||
html: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The code page of the document: `\ansicpg`, else the Macintosh's for `\mac`, else 1252.
|
||||
fn document_cp(&self) -> u32 {
|
||||
self.ansicpg.unwrap_or(if self.mac { 10000 } else { 1252 })
|
||||
}
|
||||
|
||||
/// The code page of bytes in font `font` (else the default font).
|
||||
fn code_page(&self, font: Option<i64>) -> u32 {
|
||||
font.or(self.deff).and_then(|f| self.fonts.get(&f)).and_then(|cs| charset_code_page(*cs)).unwrap_or_else(|| self.document_cp())
|
||||
}
|
||||
|
||||
fn full(&self) -> bool {
|
||||
self.text.len() >= self.max
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
let bytes = std::mem::take(&mut self.pending);
|
||||
self.decode(&bytes);
|
||||
}
|
||||
|
||||
fn decode(&mut self, bytes: &[u8]) {
|
||||
if !bytes.is_empty() {
|
||||
let decoded = decode(bytes, code_page_label(i64::from(self.pending_cp)).unwrap_or("windows-1252"));
|
||||
self.push_str(&decoded);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the whole characters waiting, and keeps a lead byte whose second is not here yet.
|
||||
fn flush_whole(&mut self) {
|
||||
let mut cut = 0usize;
|
||||
let mut i = 0usize;
|
||||
while i < self.pending.len() {
|
||||
let step = if self.pending.get(i).is_some_and(|b| is_lead(self.pending_cp, *b)) { 2 } else { 1 };
|
||||
if i.saturating_add(step) > self.pending.len() {
|
||||
break;
|
||||
}
|
||||
i = i.saturating_add(step);
|
||||
cut = i;
|
||||
}
|
||||
let rest = self.pending.split_off(cut);
|
||||
self.flush();
|
||||
self.pending = rest;
|
||||
}
|
||||
|
||||
fn push_str(&mut self, s: &str) {
|
||||
for c in s.chars() {
|
||||
// Controls other than the line end and the tab are not text.
|
||||
if c.is_control() && c != '\n' && c != '\t' {
|
||||
continue;
|
||||
}
|
||||
if self.full() {
|
||||
self.cut = true;
|
||||
return;
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
fn char(&mut self, c: char) {
|
||||
self.flush_high();
|
||||
self.flush();
|
||||
self.push_str(c.encode_utf8(&mut [0; 4]));
|
||||
}
|
||||
|
||||
fn flush_high(&mut self) {
|
||||
if self.high.take().is_some() {
|
||||
self.push_str("\u{FFFD}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of 8-bit text in code page `cp`.
|
||||
fn byte(&mut self, b: u8, cp: u32) {
|
||||
self.flush_high();
|
||||
if cp != self.pending_cp {
|
||||
self.flush();
|
||||
self.pending_cp = cp;
|
||||
}
|
||||
if self.pending.len() >= PENDING {
|
||||
self.flush_whole();
|
||||
}
|
||||
// (A buffer that is full of one character's bytes is not a text any code page here
|
||||
// has; it is let grow by one rather than dropped.)
|
||||
self.pending.push(b);
|
||||
}
|
||||
|
||||
fn unit(&mut self, u: u16) {
|
||||
self.flush();
|
||||
match (self.high.take(), u) {
|
||||
(Some(h), 0xDC00..=0xDFFF) => {
|
||||
let c = 0x10000u32.saturating_add((u32::from(h).saturating_sub(0xD800)) << 10).saturating_add(u32::from(u).saturating_sub(0xDC00));
|
||||
self.push_str(&char::from_u32(c).unwrap_or('\u{FFFD}').to_string());
|
||||
}
|
||||
(Some(_), _) => {
|
||||
self.push_str("\u{FFFD}");
|
||||
self.unit(u);
|
||||
}
|
||||
(None, 0xD800..=0xDBFF) => self.high = Some(u),
|
||||
(None, 0xDC00..=0xDFFF) => self.push_str("\u{FFFD}"),
|
||||
(None, u) => self.push_str(&char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}').to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of an RTF document: paragraphs and lines as newlines, tabs, `\'hh` bytes in the
|
||||
/// code page of the font they are set in (its `\fcharset`, else the document's `\ansicpg`,
|
||||
/// Windows-1252 when it names none), `\uN` with its `\ucN` fallback skipped, and none of the
|
||||
/// font table, colours, styles, pictures, objects, headers or footers, nor any `{\*…}`
|
||||
/// destination (for encapsulated HTML, [MS-OXRTFEX], that leaves the text the document shows
|
||||
/// and not the `\htmltag` HTML, and the text as it is for `\fromtext`). Control characters
|
||||
/// other than the line end and the tab are dropped. At most `max` bytes of text; whether more
|
||||
/// was cut is the second part of the answer. Groups nest at most [`MAX_RTF_DEPTH`] deep, else
|
||||
/// an error; unbalanced braces are read as they come.
|
||||
pub fn rtf_text(rtf: &[u8], max: usize) -> Result<(String, bool)> {
|
||||
read(rtf, max, false).map(|(text, cut)| (tidy(&text), cut))
|
||||
}
|
||||
|
||||
/// The HTML a document keeps when it is encapsulated HTML ([MS-OXRTFEX] 2.2.3): what its
|
||||
/// `\htmltag` groups hold and the text no `\htmlrtf` suppresses, under the same caps as
|
||||
/// [`rtf_text`]. `None` for any other document. The HTML is as hostile as the file: whoever
|
||||
/// shows it reads it as any other message's.
|
||||
pub fn rtf_html(rtf: &[u8], max: usize) -> Result<Option<(String, bool)>> {
|
||||
if !encapsulates_html(rtf) {
|
||||
return Ok(None);
|
||||
}
|
||||
read(rtf, max, true).map(Some)
|
||||
}
|
||||
|
||||
/// Whether one of the document's first ten tokens says it was HTML (2.2.3.1).
|
||||
fn encapsulates_html(rtf: &[u8]) -> bool {
|
||||
let mut i = rtf.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rtf.len());
|
||||
if rtf.get(i..i.saturating_add(5)) != Some(b"{\\rtf") {
|
||||
return false;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
match rtf.get(i) {
|
||||
Some(b'{') => i = i.saturating_add(1),
|
||||
Some(b'\\') => {
|
||||
let from = i.saturating_add(1);
|
||||
i = from;
|
||||
while rtf.get(i).is_some_and(u8::is_ascii_alphabetic) {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
let word = rtf.get(from..i).unwrap_or_default();
|
||||
if word.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let digits = i;
|
||||
while rtf.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
match word {
|
||||
b"fromhtml" => return rtf.get(digits..i) != Some(b"0"),
|
||||
b"fromtext" => return false,
|
||||
_ => {}
|
||||
}
|
||||
if rtf.get(i) == Some(&b' ') {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn read(rtf: &[u8], max: usize, html: bool) -> Result<(String, bool)> {
|
||||
let start = rtf.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rtf.len());
|
||||
if rtf.get(start..start.saturating_add(5)) != Some(b"{\\rtf") {
|
||||
return Err(Error::Corrupt("not rich text"));
|
||||
}
|
||||
let mut out = Out::new(max);
|
||||
out.html = html;
|
||||
let mut stack: Vec<Group> = Vec::new();
|
||||
let mut cur = Group { uc: 1, skip: false, fonttbl: false, defining: 0, font: None, suppressed: false, in_tag: false };
|
||||
let mut star = false;
|
||||
let mut first = false;
|
||||
let mut skip_chars = 0u32;
|
||||
let mut i = start;
|
||||
while let Some(&b) = rtf.get(i) {
|
||||
i = i.saturating_add(1);
|
||||
match b {
|
||||
b'{' => {
|
||||
if stack.len() >= MAX_RTF_DEPTH {
|
||||
return Err(Error::Corrupt("rich text nested too deeply"));
|
||||
}
|
||||
stack.push(cur);
|
||||
first = true;
|
||||
skip_chars = 0;
|
||||
}
|
||||
b'}' => {
|
||||
out.flush();
|
||||
out.flush_high();
|
||||
if let Some(g) = stack.pop() {
|
||||
cur = g;
|
||||
}
|
||||
first = false;
|
||||
skip_chars = 0;
|
||||
}
|
||||
b'\r' | b'\n' => {}
|
||||
b'\\' => {
|
||||
let Some(&n) = rtf.get(i) else { break };
|
||||
i = i.saturating_add(1);
|
||||
match n {
|
||||
b'\\' | b'{' | b'}' => {
|
||||
first = false;
|
||||
if !cur.hidden() && !take(&mut skip_chars) {
|
||||
out.char(char::from(n));
|
||||
}
|
||||
}
|
||||
// A backslash before a line end is a paragraph end.
|
||||
b'\r' | b'\n' => {
|
||||
first = false;
|
||||
if !cur.hidden() {
|
||||
out.char('\n');
|
||||
}
|
||||
}
|
||||
b'\'' => {
|
||||
first = false;
|
||||
let hex =
|
||||
rtf.get(i..i.saturating_add(2)).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
if let Some(v) = hex {
|
||||
i = i.saturating_add(2);
|
||||
if !cur.hidden() && !take(&mut skip_chars) {
|
||||
let cp = out.code_page(cur.font);
|
||||
out.byte(v, cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
b'*' => {
|
||||
cur.skip = true;
|
||||
first = false;
|
||||
star = true;
|
||||
}
|
||||
b'~' => symbol(&mut out, &cur, '\u{A0}', &mut first),
|
||||
b'_' => {
|
||||
let hyphen = if out.html { '\u{AD}' } else { '\u{2011}' };
|
||||
symbol(&mut out, &cur, hyphen, &mut first);
|
||||
}
|
||||
b'-' | b'|' | b':' => first = false,
|
||||
n if n.is_ascii_alphabetic() => {
|
||||
let from = i.saturating_sub(1);
|
||||
while rtf.get(i).is_some_and(u8::is_ascii_alphabetic) {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
let word = rtf.get(from..i).and_then(|w| std::str::from_utf8(w).ok()).unwrap_or_default();
|
||||
let word = if word.len() > 32 { word.get(..32).unwrap_or(word) } else { word };
|
||||
// An optional parameter, then at most one space of delimiter.
|
||||
let mut neg = false;
|
||||
if rtf.get(i) == Some(&b'-') {
|
||||
neg = true;
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
let mut num: Option<i64> = None;
|
||||
while let Some(d) = rtf.get(i).filter(|d| d.is_ascii_digit()) {
|
||||
num = Some(num.unwrap_or(0).saturating_mul(10).saturating_add(i64::from(d.saturating_sub(b'0'))).min(1 << 40));
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
let num = num.map(|v| if neg { v.saturating_neg() } else { v });
|
||||
if rtf.get(i) == Some(&b' ') {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
let was_first = std::mem::replace(&mut first, false);
|
||||
// The HTML of a tag is read whatever suppresses what is round it, by the
|
||||
// document's own code page (2.2.3.2).
|
||||
if html && std::mem::take(&mut star) && word == "htmltag" && !stack.last().is_some_and(|g| g.skip) {
|
||||
cur.skip = false;
|
||||
cur.in_tag = true;
|
||||
cur.suppressed = false;
|
||||
cur.font = None;
|
||||
}
|
||||
if was_first && SKIPPED.contains(&word.to_ascii_lowercase().as_str()) {
|
||||
cur.skip = true;
|
||||
cur.fonttbl = word.eq_ignore_ascii_case("fonttbl");
|
||||
}
|
||||
skip_chars = 0;
|
||||
control(word, num, &mut cur, &mut out, &mut skip_chars, &mut i, rtf);
|
||||
}
|
||||
_ => first = false,
|
||||
}
|
||||
}
|
||||
b'\t' => {
|
||||
first = false;
|
||||
if !cur.hidden() && !take(&mut skip_chars) {
|
||||
out.char('\t');
|
||||
}
|
||||
}
|
||||
0x00..=0x1F => {}
|
||||
_ => {
|
||||
first = false;
|
||||
if cur.hidden() || take(&mut skip_chars) {
|
||||
continue;
|
||||
}
|
||||
if b.is_ascii() {
|
||||
out.char(char::from(b));
|
||||
} else {
|
||||
let cp = out.code_page(cur.font);
|
||||
out.byte(b, cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flush();
|
||||
out.flush_high();
|
||||
let cut = out.cut;
|
||||
Ok((out.text, cut))
|
||||
}
|
||||
|
||||
fn take(skip: &mut u32) -> bool {
|
||||
if *skip > 0 {
|
||||
*skip = skip.saturating_sub(1);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol(out: &mut Out, cur: &Group, c: char, first: &mut bool) {
|
||||
*first = false;
|
||||
if !cur.hidden() {
|
||||
out.char(c);
|
||||
}
|
||||
}
|
||||
|
||||
fn control(word: &str, num: Option<i64>, cur: &mut Group, out: &mut Out, skip_chars: &mut u32, i: &mut usize, rtf: &[u8]) {
|
||||
match word {
|
||||
"ansicpg" => out.ansicpg = num.and_then(|n| u32::try_from(n).ok()),
|
||||
"mac" => out.mac = true,
|
||||
// The fonts: which character set each has, and which one the text is in.
|
||||
"f" if cur.fonttbl => cur.defining = num.unwrap_or(0),
|
||||
"fcharset" if cur.fonttbl => {
|
||||
if let Some(cs) = num
|
||||
&& (out.fonts.len() < MAX_FONTS || out.fonts.contains_key(&cur.defining))
|
||||
{
|
||||
out.fonts.insert(cur.defining, cs);
|
||||
}
|
||||
}
|
||||
"f" if !cur.skip => cur.font = num,
|
||||
"deff" => out.deff = num,
|
||||
"plain" if !cur.skip => cur.font = None,
|
||||
"uc" => cur.uc = num.and_then(|n| u32::try_from(n).ok()).unwrap_or(1).min(16),
|
||||
"htmlrtf" if out.html && !cur.in_tag => cur.suppressed = num != Some(0),
|
||||
"u" if !cur.hidden() => {
|
||||
if let Some(n) = num {
|
||||
// A negative number is the unit minus 65536; the fallback after it is skipped.
|
||||
let unit = if n < 0 { n.saturating_add(65536) } else { n };
|
||||
out.unit(u16::try_from(unit & 0xFFFF).unwrap_or(0xFFFD));
|
||||
*skip_chars = cur.uc;
|
||||
}
|
||||
}
|
||||
"bin" => {
|
||||
let n = num.and_then(|n| usize::try_from(n).ok()).unwrap_or(0);
|
||||
*i = i.saturating_add(n).min(rtf.len());
|
||||
}
|
||||
"par" | "line" if !cur.hidden() => out.char('\n'),
|
||||
"sect" | "page" | "row" if !cur.hidden() && !out.html => out.char('\n'),
|
||||
"tab" if !cur.hidden() => out.char('\t'),
|
||||
"cell" if !cur.hidden() && !out.html => out.char('\t'),
|
||||
"emdash" if !cur.hidden() => out.char('\u{2014}'),
|
||||
"endash" if !cur.hidden() => out.char('\u{2013}'),
|
||||
"lquote" if !cur.hidden() => out.char('\u{2018}'),
|
||||
"rquote" if !cur.hidden() => out.char('\u{2019}'),
|
||||
"ldblquote" if !cur.hidden() => out.char('\u{201C}'),
|
||||
"rdblquote" if !cur.hidden() => out.char('\u{201D}'),
|
||||
"bullet" if !cur.hidden() => out.char('\u{2022}'),
|
||||
"emspace" | "enspace" | "qmspace" if !cur.hidden() => out.char(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// At most one blank line in a row, and no blanks at the ends.
|
||||
fn tidy(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut newlines = 0u32;
|
||||
for c in s.chars() {
|
||||
if c == '\n' {
|
||||
newlines = newlines.saturating_add(1);
|
||||
if newlines > 2 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
newlines = 0;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "outlook_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Compressed rich text ([MS-OXRTFCP] section 3 samples) and the text of RTF.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::{Error, MAX_RTF_DEPTH, crc32, decompress_rtf, rtf_html, rtf_text};
|
||||
|
||||
/// [MS-OXRTFCP] 3.1.1: a simple compressed RTF.
|
||||
const EXAMPLE_1: [u8; 49] = [
|
||||
0x2d, 0x00, 0x00, 0x00, 0x2b, 0x00, 0x00, 0x00, 0x4c, 0x5a, 0x46, 0x75, 0xf1, 0xc5, 0xc7, 0xa7, 0x03, 0x00, 0x0a, 0x00, 0x72, 0x63, 0x70, 0x67,
|
||||
0x31, 0x32, 0x35, 0x42, 0x32, 0x0a, 0xf3, 0x20, 0x68, 0x65, 0x6c, 0x09, 0x00, 0x20, 0x62, 0x77, 0x05, 0xb0, 0x6c, 0x64, 0x7d, 0x0a, 0x80, 0x0f,
|
||||
0xa0,
|
||||
];
|
||||
|
||||
/// [MS-OXRTFCP] 3.1.2: a reference that reads past the write position.
|
||||
const EXAMPLE_2: [u8; 30] = [
|
||||
0x1a, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x4c, 0x5a, 0x46, 0x75, 0xe2, 0xd4, 0x4b, 0x51, 0x41, 0x00, 0x04, 0x20, 0x57, 0x58, 0x59, 0x5a,
|
||||
0x0d, 0x6e, 0x7d, 0x01, 0x0e, 0xb0,
|
||||
];
|
||||
|
||||
fn mela(rtf: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&u32::try_from(rtf.len() + 12).unwrap().to_le_bytes());
|
||||
v.extend_from_slice(&u32::try_from(rtf.len()).unwrap().to_le_bytes());
|
||||
v.extend_from_slice(b"MELA");
|
||||
v.extend_from_slice(&[0; 4]);
|
||||
v.extend_from_slice(rtf);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_example_1_decompresses() {
|
||||
let out = decompress_rtf(&EXAMPLE_1, 1 << 20).unwrap();
|
||||
assert_eq!(out, b"{\\rtf1\\ansi\\ansicpg1252\\pard hello world}\r\n");
|
||||
assert_eq!(crc32(&EXAMPLE_1[16..]), 0xa7c7_c5f1);
|
||||
assert_eq!(rtf_text(&out, 1000).unwrap(), ("hello world".to_string(), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_example_2_reads_past_the_write_position() {
|
||||
let out = decompress_rtf(&EXAMPLE_2, 1 << 20).unwrap();
|
||||
assert_eq!(out, b"{\\rtf1 WXYZWXYZWXYZWXYZWXYZ}");
|
||||
assert_eq!(out.len(), 0x1c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_is_taken_as_it_is() {
|
||||
let rtf = b"{\\rtf1 plain}";
|
||||
assert_eq!(decompress_rtf(&mela(rtf), 100).unwrap(), rtf);
|
||||
// A header that lies about its size is held to what is there.
|
||||
let mut lying = mela(rtf);
|
||||
lying.get_mut(4..8).unwrap().copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(decompress_rtf(&lying, 100).unwrap(), rtf);
|
||||
assert!(matches!(decompress_rtf(&mela(rtf), 3), Err(Error::TooLarge)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_crc_is_an_error() {
|
||||
let mut bad = EXAMPLE_1;
|
||||
bad[12] ^= 1;
|
||||
assert!(matches!(decompress_rtf(&bad, 1 << 20), Err(Error::BadChecksum)));
|
||||
let mut bad = EXAMPLE_1;
|
||||
bad[20] ^= 0x40;
|
||||
assert!(decompress_rtf(&bad, 1 << 20).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_an_error_at_every_length() {
|
||||
for n in 0..EXAMPLE_1.len() {
|
||||
assert!(decompress_rtf(&EXAMPLE_1[..n], 1 << 20).is_err(), "{n}");
|
||||
}
|
||||
// A header whose size is right but whose runs have no end mark.
|
||||
let mut v = EXAMPLE_1[..40].to_vec();
|
||||
v[..4].copy_from_slice(&28u32.to_le_bytes());
|
||||
assert!(decompress_rtf(&v, 1 << 20).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_that_lies_about_the_size_costs_nothing() {
|
||||
let mut huge = EXAMPLE_1;
|
||||
huge[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(matches!(decompress_rtf(&huge, 1 << 20), Err(Error::TooLarge)));
|
||||
// Under the cap it is the stream that decides.
|
||||
let mut small = EXAMPLE_1;
|
||||
small[4..8].copy_from_slice(&1000u32.to_le_bytes());
|
||||
assert_eq!(decompress_rtf(&small, 1 << 20).unwrap().len(), 0x2b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_capped_while_it_is_made() {
|
||||
assert!(matches!(decompress_rtf(&EXAMPLE_1, 10), Err(Error::TooLarge)));
|
||||
// A long stream (no end mark) is stopped at the cap, not at its end.
|
||||
let mut body = Vec::new();
|
||||
for _ in 0..200_000 {
|
||||
body.push(0x00);
|
||||
body.extend_from_slice(b"abcdefgh");
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&u32::try_from(body.len() + 12).unwrap().to_le_bytes());
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.extend_from_slice(b"LZFu");
|
||||
v.extend_from_slice(&crc32(&body).to_le_bytes());
|
||||
v.extend_from_slice(&body);
|
||||
assert!(matches!(decompress_rtf(&v, 1 << 16), Err(Error::TooLarge)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_is_an_error() {
|
||||
let mut v = EXAMPLE_1;
|
||||
v[8..12].copy_from_slice(b"XXXX");
|
||||
assert!(matches!(decompress_rtf(&v, 100), Err(Error::Corrupt(_))));
|
||||
}
|
||||
|
||||
fn text(rtf: &str) -> String {
|
||||
rtf_text(rtf.as_bytes(), 1 << 20).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_and_tabs() {
|
||||
assert_eq!(text("{\\rtf1\\ansi\\deff0 One\\par Two\\line Three\\tab x\\par}"), "One\nTwo\nThree\tx");
|
||||
assert_eq!(text("{\\rtf1 a\\par\\par\\par\\par\\par b}"), "a\n\nb");
|
||||
assert_eq!(text("{\\rtf1 braces \\{ \\} and \\\\ end}"), "braces { } and \\ end");
|
||||
assert_eq!(text("{\\rtf1 line\r\nbreaks in the file are not breaks}"), "linebreaks in the file are not breaks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_escapes_use_the_code_page() {
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg1252 caf\\'e9 \\'80}"), "caf\u{e9} \u{20ac}");
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg1251 \\'cf\\'f0\\'e8\\'e2\\'e5\\'f2}"), "\u{41f}\u{440}\u{438}\u{432}\u{435}\u{442}");
|
||||
// Two bytes of one character (Shift JIS).
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg932 \\'82\\'a0}"), "\u{3042}");
|
||||
// No code page: Windows-1252.
|
||||
assert_eq!(text("{\\rtf1 \\'e9}"), "\u{e9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_skip_their_fallback() {
|
||||
assert_eq!(text("{\\rtf1\\uc1\\u8364? x}"), "\u{20ac} x");
|
||||
assert_eq!(text("{\\rtf1\\uc2\\u8364\\'80\\'80 x}"), "\u{20ac} x");
|
||||
assert_eq!(text("{\\rtf1\\uc0\\u8364 x}"), "\u{20ac}x");
|
||||
assert_eq!(text("{\\rtf1\\u-3913 ?}"), "\u{f0b7}");
|
||||
assert_eq!(text("{\\rtf1\\u-10179?\\u-8704? x}"), "\u{1f600} x");
|
||||
// A lone surrogate is a replacement character, not a panic.
|
||||
assert_eq!(text("{\\rtf1\\u-10179? x}"), "\u{fffd} x");
|
||||
// \ucN belongs to its group.
|
||||
assert_eq!(text("{\\rtf1{\\uc0}\\u65?B}"), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_groups_and_skipped_destinations() {
|
||||
let rtf = "{\\rtf1\\ansi{\\fonttbl{\\f0 Arial;}}{\\colortbl;\\red0\\green0\\blue0;}{\\stylesheet{\\s1 Heading;}}{\\*\\generator Hidden;}{\\info{\\title T}}\
|
||||
{\\pict\\wmetafile8 0102030405}{\\header Head}\\pard{\\b bold {\\i both}} plain{\\*\\htmltag64 <b>}}";
|
||||
assert_eq!(text(rtf), "bold both plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encapsulated_text_and_html_give_what_the_document_shows() {
|
||||
assert_eq!(text("{\\rtf1\\ansi\\fromtext\\deff0{\\fonttbl{\\f0\\fswiss Arial;}}\\pard Hello\\par}"), "Hello");
|
||||
let html = "{\\rtf1\\ansi\\fromhtml1 {\\*\\htmltag19 <html>}\\htmlrtf {\\htmlrtf0 Hello {\\*\\htmltag84 <b>}there\\htmlrtf \\par\\htmlrtf0 }}";
|
||||
assert_eq!(text(html), "Hello there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_data_is_skipped_by_its_count() {
|
||||
let mut rtf = b"{\\rtf1 a{\\pict\\bin5 }}{b".to_vec();
|
||||
let at = rtf.iter().position(|&b| b == b'5').unwrap() + 2;
|
||||
rtf.splice(at..at, *b"}{\\par");
|
||||
assert_eq!(rtf_text(&rtf, 100).unwrap().0, "ab");
|
||||
// A count past the end is the end.
|
||||
assert_eq!(rtf_text(b"{\\rtf1 a\\bin99999999999 bc", 100).unwrap().0, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_an_error_or_what_could_be_read() {
|
||||
assert!(rtf_text(b"plain text", 100).is_err());
|
||||
assert!(rtf_text(b"", 100).is_err());
|
||||
assert_eq!(text("{\\rtf1 open {group {never closed"), "open group never closed");
|
||||
assert_eq!(text("{\\rtf1 closed}} too} many} text"), "closed too many text");
|
||||
assert_eq!(text("{\\rtf1 ends in a slash\\"), "ends in a slash");
|
||||
assert_eq!(text("{\\rtf1 \\'zz bad hex}"), "zz bad hex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_is_capped() {
|
||||
let deep = format!("{{\\rtf1 {}x{}", "{".repeat(MAX_RTF_DEPTH + 10), "}".repeat(MAX_RTF_DEPTH + 10));
|
||||
assert!(matches!(rtf_text(deep.as_bytes(), 100), Err(Error::Corrupt(_))));
|
||||
let ok = format!("{{\\rtf1 {}x{}", "{".repeat(100), "}".repeat(101));
|
||||
assert_eq!(text(&ok), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_capped() {
|
||||
let long = format!("{{\\rtf1 {}}}", "word ".repeat(100_000));
|
||||
let (t, cut) = rtf_text(long.as_bytes(), 1000).unwrap();
|
||||
assert!(t.len() <= 1000 && cut);
|
||||
assert!(!rtf_text(b"{\\rtf1 short}", 1000).unwrap().1);
|
||||
// Folded blank lines still say the text was cut.
|
||||
let folded = format!("{{\\rtf1 {}}}", "x\\par\\par\\par\\par ".repeat(1000));
|
||||
let (t, cut) = rtf_text(folded.as_bytes(), 1000).unwrap();
|
||||
assert!(cut && t.len() < 1000, "{}", t.len());
|
||||
let multibyte = format!("{{\\rtf1 {}}}", "\\u233?".repeat(10_000));
|
||||
let t = rtf_text(multibyte.as_bytes(), 101).unwrap().0;
|
||||
assert!(t.len() <= 102 && t.chars().all(|c| c == '\u{e9}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_font_has_its_code_page() {
|
||||
// RichEdit's shape: ANSI document, a font of its own character set.
|
||||
assert_eq!(
|
||||
text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fnil\\fcharset128 Yu Gothic;}}\\f0\\'93\\'fa\\'96\\'7b\\'8c\\'ea}"),
|
||||
"\u{65e5}\u{672c}\u{8a9e}"
|
||||
);
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fnil\\fcharset204 Calibri;}}\\f0\\'cf\\'f0\\'e8 x}"), "\u{41f}\u{440}\u{438} x");
|
||||
// The font goes with its group.
|
||||
assert_eq!(
|
||||
text("{\\rtf1\\ansi\\ansicpg1252{\\fonttbl{\\f0\\fcharset204 A;}{\\f1\\fcharset161 B;}}{\\f0 \\'cf}\\'cf{\\f1\\'e1}\\f0\\'cf}"),
|
||||
"\u{41f}\u{cf}\u{3b1}\u{41f}"
|
||||
);
|
||||
// A change of font in the middle of a run of bytes.
|
||||
assert_eq!(text("{\\rtf1\\ansi{\\fonttbl{\\f0\\fcharset204 A;}{\\f1\\fcharset0 B;}}\\f0\\'cf\\f1\\'cf\\f0\\'cf}"), "\u{41f}\u{cf}\u{41f}");
|
||||
// A flat font table, \deff and no charset at all.
|
||||
assert_eq!(text("{\\rtf1\\ansi\\deff1{\\fonttbl\\f0\\fcharset0 A;\\f1\\fcharset204 B;}\\'cf}"), "\u{41f}");
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg1251{\\fonttbl{\\f0 A;}}\\f0\\'a8\\f9\\'a8}"), "\u{401}\u{401}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_of_ms_oxrtfex_reads_its_font_charset() {
|
||||
// [MS-OXRTFEX] 3.1: a 1251 document, and one font marked \fcharset0 for ¨ (0xA8, U+00A8).
|
||||
let rtf = "{\\rtf1\\ANSI\\ansicpg1251\\fromhtml1 \\deff0\r\n{\\fonttbl {\\f0\\fmodern Courier New;}{\\f1\\fswiss Arial;}{\\f2\\fswiss\\fcharset0 Arial;}}\
|
||||
\\uc1\\pard\\plain\\deftab360 \\f0\\fs24 {\\htmlrtf \\f1 \\htmlrtf0 \\'cf {\\*\\htmltag ¨}\\htmlrtf {\\f2\\'a8}\\htmlrtf0 \\'a8}}";
|
||||
assert_eq!(text(rtf), "\u{41f} \u{a8}\u{401}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_past_the_buffer_are_not_dropped() {
|
||||
let dbcs = format!("{{\\rtf1\\ansi\\ansicpg932 {}}}", "\\'82\\'a0".repeat(3000));
|
||||
assert_eq!(text(&dbcs), "\u{3042}".repeat(3000));
|
||||
// An odd byte first, so the 4096-byte mark falls inside a pair.
|
||||
let shifted = format!("{{\\rtf1\\ansi\\ansicpg932 \\'41{}}}", "\\'82\\'a0".repeat(3000));
|
||||
assert_eq!(text(&shifted), format!("A{}", "\u{3042}".repeat(3000)));
|
||||
let single = format!("{{\\rtf1\\ansi\\ansicpg1252 {}}}", "\\'e9".repeat(5000));
|
||||
assert_eq!(text(&single), "\u{e9}".repeat(5000));
|
||||
let raw: Vec<u8> = [b"{\\rtf1\\ansi\\ansicpg1252 ".as_slice(), &[0xE9u8; 5000], b"}"].concat();
|
||||
assert_eq!(rtf_text(&raw, 1 << 20).unwrap().0, "\u{e9}".repeat(5000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansicpg_wins_over_mac_in_either_order() {
|
||||
assert_eq!(text("{\\rtf1\\ansicpg1251\\mac \\'cf}"), "\u{41f}");
|
||||
assert_eq!(text("{\\rtf1\\mac\\ansicpg1251 \\'cf}"), "\u{41f}");
|
||||
assert_eq!(text("{\\rtf1\\mac \\'a5}"), "\u{2022}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_are_not_text() {
|
||||
assert_eq!(text("{\\rtf1 a\\'00b\\u0? c\\'07\\u1\\u9? d\\u10?e}"), "ab c\t d\ne");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_tab_and_a_backslash_line_end() {
|
||||
assert_eq!(text("{\\rtf1 a\tb\\\r\nc\\\nd\\\re}"), "a\tb\nc\nd\ne");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_code_pages_of_the_mail_are_the_code_pages_of_the_text() {
|
||||
// Pages the first version of the text reader lacked: KOI8-R, ISO 8859-2, GB 18030's, EUC-KR's other number.
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg20866 \\'d0\\'d2}"), "\u{43f}\u{440}");
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg28592 \\'b1}"), "\u{105}");
|
||||
assert_eq!(text("{\\rtf1\\ansi\\ansicpg51949 \\'b0\\'a1}"), "\u{ac00}");
|
||||
assert_eq!(craft_mime::charset::code_page_label(20866), Some("koi8-r"));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||
let _ = decompress_rtf(&bytes, 1 << 16);
|
||||
let _ = rtf_text(&bytes, 1 << 12);
|
||||
let mut with_start = b"{\\rtf1".to_vec();
|
||||
with_start.extend_from_slice(&bytes);
|
||||
let _ = rtf_text(&with_start, 1 << 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_streams_never_panic(at in 0usize..49, to in any::<u8>(), cut in 0usize..49) {
|
||||
let mut v = EXAMPLE_1.to_vec();
|
||||
if let Some(b) = v.get_mut(at) { *b = to; }
|
||||
v.truncate(cut.max(1));
|
||||
if let Ok(rtf) = decompress_rtf(&v, 1 << 12) { let _ = rtf_text(&rtf, 100); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_with_valid_start_is_bounded(body in "[\\\\{}a-z0-9 '*-]{0,600}", max in 0usize..300) {
|
||||
let rtf = format!("{{\\rtf1 {body}");
|
||||
if let Ok((t, _)) = rtf_text(rtf.as_bytes(), max) { prop_assert!(t.len() <= max.saturating_add(4)); }
|
||||
}
|
||||
}
|
||||
|
||||
/// The document of [MS-OXRTFEX] 2.1.3.1.3's kind: tags in groups, what isn't HTML suppressed.
|
||||
const ENCAPSULATED: &[u8] =
|
||||
b"{\\rtf1\\ansi\\ansicpg1252\\fromhtml1 \\deff0{\\fonttbl{\\f0\\fswiss Arial;}{\\f1\\fcharset204 Cyr;}}{\\colortbl;\\red0;}\
|
||||
{\\*\\htmltag64 <p>}{\\htmlrtf \\f1 \\htmlrtf0 caf\\'e9 {\\*\\htmltag <b>}{\\htmlrtf \\b \\htmlrtf0 bold{\\*\\htmltag </b>}}\
|
||||
\\htmlrtf \\par \\htmlrtf0 {\\*\\htmltag &}\\htmlrtf &\\htmlrtf0 \\u8364?{\\*\\mhtmltag <skipped>}{\\*\\other no}{\\*\\htmltag </p>}}}";
|
||||
|
||||
#[test]
|
||||
fn encapsulated_html_comes_out_as_it_was() {
|
||||
let (html, cut) = rtf_html(ENCAPSULATED, 1 << 16).unwrap().unwrap();
|
||||
assert!(!cut);
|
||||
assert_eq!(html, "<p>caf\u{439} <b>bold</b>&\u{20AC}</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_is_kept_even_where_the_text_round_it_is_suppressed() {
|
||||
let rtf = b"{\\rtf1\\fromhtml1 \\htmlrtf {\\*\\htmltag <i>}gone\\htmlrtf0 kept}";
|
||||
assert_eq!(rtf_html(rtf, 100).unwrap().unwrap().0, "<i>kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_document_that_says_so_is_html() {
|
||||
assert_eq!(rtf_html(b"{\\rtf1\\ansi plain}", 100).unwrap(), None);
|
||||
assert_eq!(rtf_html(b"{\\rtf1\\fromtext hello}", 100).unwrap(), None);
|
||||
assert_eq!(rtf_html(b"{\\rtf1 text\\fromhtml1 }", 100).unwrap(), None, "past a token that isn't a control word");
|
||||
assert_eq!(rtf_html(b"{\\rtf1\\a\\b\\c\\d\\e\\f\\g\\h\\i\\j\\fromhtml1 }", 100).unwrap(), None, "past the first ten tokens");
|
||||
assert_eq!(rtf_html(b"not rich text", 100).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encapsulated_html_is_capped_and_a_deep_nest_refused() {
|
||||
let mut rtf = b"{\\rtf1\\fromhtml1 ".to_vec();
|
||||
rtf.extend(std::iter::repeat_n(b'x', 1000));
|
||||
let (html, cut) = rtf_html(&rtf, 10).unwrap().unwrap();
|
||||
assert!(cut && html.len() <= 10);
|
||||
let mut deep = b"{\\rtf1\\fromhtml1 ".to_vec();
|
||||
deep.extend(std::iter::repeat_n(b'{', MAX_RTF_DEPTH + 2));
|
||||
assert!(rtf_html(&deep, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_of_encapsulated_html_is_still_what_it_shows() {
|
||||
let rtf = b"{\\rtf1\\fromhtml1 {\\*\\htmltag <p>}\\htmlrtf x\\htmlrtf0 hello}";
|
||||
assert_eq!(rtf_text(rtf, 100).unwrap().0, "xhello");
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_bytes_read_as_html_never_panic(data in proptest::collection::vec(any::<u8>(), 0..2000)) {
|
||||
let mut rtf = b"{\\rtf1\\fromhtml1 ".to_vec();
|
||||
rtf.extend_from_slice(&data);
|
||||
let _ = rtf_html(&rtf, 1 << 12);
|
||||
}
|
||||
}
|
||||
|
||||
/// The font character sets are the crate's table, but for ANSI (0), which is Windows-1252
|
||||
/// whatever the document's code page, and the sets with no text code page here.
|
||||
#[test]
|
||||
fn font_character_sets_name_their_code_pages() {
|
||||
let want = [
|
||||
(0, 1252),
|
||||
(77, 10000),
|
||||
(128, 932),
|
||||
(129, 949),
|
||||
(134, 936),
|
||||
(136, 950),
|
||||
(161, 1253),
|
||||
(162, 1254),
|
||||
(163, 1258),
|
||||
(177, 1255),
|
||||
(178, 1256),
|
||||
(186, 1257),
|
||||
(204, 1251),
|
||||
(222, 874),
|
||||
(238, 1250),
|
||||
];
|
||||
for charset in -1..=256 {
|
||||
let expected = want.iter().find(|(c, _)| *c == charset).map(|(_, cp)| *cp);
|
||||
assert_eq!(super::charset_code_page(charset), expected, "{charset}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checksum_is_the_specifications() {
|
||||
// [MS-OXRTFCP] 3.2.3: the CRC of the compressed bytes of the first example is its header's.
|
||||
assert_eq!(crc32(&EXAMPLE_1[16..]), u32::from_le_bytes([0xf1, 0xc5, 0xc7, 0xa7]));
|
||||
assert_eq!(crc32(b""), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user