designcraft/crates/textimport/src/rtf.rs

291 lines
11 KiB
Rust

//! RTF → story: text, paragraphs, line breaks, tabs, bold/italic/underline/strikethrough, size,
//! Unicode (`\uN`) and hex (`\'hh`) characters. Destinations that aren't body text (font and
//! colour tables, stylesheet, info, pictures, `\*` groups) are skipped.
use designcraft_doc::{CharAttrs, CharFormat, Story, StoryId};
use crate::{ImportError, Imported};
#[derive(Clone, Default)]
struct State {
bold: bool,
italic: bool,
underline: bool,
strike: bool,
size: Option<f64>,
/// Inside a destination whose text is ignored.
skip: bool,
/// Characters to skip after `\uN` (`\ucN`).
uc: usize,
}
impl State {
fn format(&self) -> CharFormat {
let font_style = match (self.bold, self.italic) {
(true, true) => Some("Bold Italic".to_string()),
(true, false) => Some("Bold".into()),
(false, true) => Some("Italic".into()),
_ => None,
};
CharFormat {
style: designcraft_doc::story::NO_CHAR_STYLE.into(),
over: CharAttrs {
font_style,
underline: self.underline.then_some(true),
strikethrough: self.strike.then_some(true),
size: self.size,
..Default::default()
},
}
}
}
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"footer",
"footnote",
"listtable",
"listoverridetable",
"rsidtbl",
"generator",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"fldinst",
"object",
];
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let src = String::from_utf8_lossy(bytes);
if !src.trim_start().starts_with("{\\rtf") {
return Err(ImportError::Corrupt("not an RTF file".into()));
}
let mut st = Story::new(StoryId(0));
let mut stack: Vec<State> = vec![State { uc: 1, ..Default::default() }];
let mut pending = 0usize; // characters still to skip after \uN
let mut buf = String::new();
let mut fmt = stack[0].format();
let mut warnings = Vec::new();
let flush = |st: &mut Story, buf: &mut String, fmt: &CharFormat| {
if !buf.is_empty() {
let len = st.len();
st.insert_with(len, buf, fmt.clone());
buf.clear();
}
};
let chars: Vec<char> = src.chars().collect();
let mut i = 0;
// The first `{` opens the document group.
let mut first = true;
while i < chars.len() {
let c = chars[i];
match c {
'{' => {
flush(&mut st, &mut buf, &fmt);
let top = stack.last().cloned().unwrap_or_default();
if !first {
stack.push(top);
}
first = false;
// `{\*\dest …}`: an ignorable destination.
if chars.get(i + 1) == Some(&'\\')
&& chars.get(i + 2) == Some(&'*')
&& let Some(s) = stack.last_mut()
{
s.skip = true;
}
i += 1;
}
'}' => {
flush(&mut st, &mut buf, &fmt);
if stack.len() > 1 {
stack.pop();
}
fmt = stack.last().map(State::format).unwrap_or_default();
i += 1;
}
'\\' => {
let next = chars.get(i + 1).copied().unwrap_or(' ');
if next == '\'' {
// \'hh: a Windows-1252 byte.
let hex: String = chars.iter().skip(i + 2).take(2).collect();
i += 4;
if pending > 0 {
pending -= 1;
continue;
}
if let Ok(b) = u8::from_str_radix(&hex, 16)
&& !stack.last().is_some_and(|s| s.skip)
{
buf.push(cp1252(b));
}
continue;
}
if !next.is_ascii_alphabetic() {
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
let skip = stack.last().is_some_and(|s| s.skip);
if !skip {
match next {
'\\' | '{' | '}' => buf.push(next),
'~' => buf.push('\u{A0}'),
'-' => buf.push('\u{AD}'),
'_' => buf.push('\u{2011}'),
'\n' | '\r' => {
flush(&mut st, &mut buf, &fmt);
let len = st.len();
st.insert(len, "\n");
}
_ => {}
}
}
i += 2;
continue;
}
// A control word with an optional numeric parameter and one delimiting space.
let mut j = i + 1;
while j < chars.len() && chars[j].is_ascii_alphabetic() {
j += 1;
}
let word: String = chars[i + 1..j].iter().collect();
let mut k = j;
if k < chars.len() && (chars[k] == '-' || chars[k].is_ascii_digit()) {
k += 1;
while k < chars.len() && chars[k].is_ascii_digit() {
k += 1;
}
}
let param: Option<i64> = chars[j..k].iter().collect::<String>().parse().ok();
if k < chars.len() && chars[k] == ' ' {
k += 1;
}
i = k;
let skip = stack.last().is_some_and(|s| s.skip);
if SKIP.contains(&word.as_str()) {
flush(&mut st, &mut buf, &fmt);
if let Some(s) = stack.last_mut() {
s.skip = true;
}
if word == "pict" && !warnings.iter().any(|w: &String| w.starts_with("Pictures")) {
warnings.push("Pictures in the RTF file were not imported.".into());
}
continue;
}
if skip {
continue;
}
let on = param != Some(0);
let mut restyle = |f: &dyn Fn(&mut State)| {
flush(&mut st, &mut buf, &fmt);
if let Some(s) = stack.last_mut() {
f(s);
fmt = s.format();
}
};
match word.as_str() {
"par" | "sect" => {
flush(&mut st, &mut buf, &fmt);
let len = st.len();
st.insert_with(len, "\n", fmt.clone());
}
"line" => buf.push(designcraft_doc::FORCED_LINE_BREAK),
"tab" => buf.push('\t'),
"page" => buf.push(designcraft_doc::PAGE_BREAK),
"column" => buf.push(designcraft_doc::COLUMN_BREAK),
"emdash" => buf.push('\u{2014}'),
"endash" => buf.push('\u{2013}'),
"bullet" => buf.push('\u{2022}'),
"lquote" => buf.push('\u{2018}'),
"rquote" => buf.push('\u{2019}'),
"ldblquote" => buf.push('\u{201C}'),
"rdblquote" => buf.push('\u{201D}'),
"b" => restyle(&|s| s.bold = on),
"i" => restyle(&|s| s.italic = on),
"ul" => restyle(&|s| s.underline = on),
"ulnone" => restyle(&|s| s.underline = false),
"strike" => restyle(&|s| s.strike = on),
"fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)),
"plain" => restyle(&|s| {
s.bold = false;
s.italic = false;
s.underline = false;
s.strike = false;
s.size = None;
}),
"uc" => {
if let (Some(s), Some(n)) = (stack.last_mut(), param) {
s.uc = n.max(0) as usize;
}
}
"u" => {
if let Some(n) = param {
let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 };
if let Some(ch) = char::from_u32(cp) {
buf.push(ch);
}
pending = stack.last().map_or(1, |s| s.uc);
}
}
_ => {}
}
}
'\r' | '\n' => i += 1,
_ => {
i += 1;
if pending > 0 {
pending -= 1;
continue;
}
if !stack.last().is_some_and(|s| s.skip) {
buf.push(c);
}
}
}
}
flush(&mut st, &mut buf, &fmt);
// A trailing \par leaves an empty last paragraph.
if st.text.ends_with('\n') {
let n = st.len();
st.delete(n - 1..n);
}
Ok(Imported { story: st, para_styles: vec![], char_styles: vec![], warnings })
}
/// Windows-1252 byte → char.
fn cp1252(b: u8) -> char {
const HI: [u16; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
if (0x80..0xA0).contains(&b) { char::from_u32(HI[(b - 0x80) as usize] as u32).unwrap_or('?') } else { b as char }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_rtf_text_and_formatting() {
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
\f0\fs24 Hello {\b bold} and {\i italic\i0 plain}\par
Caf\'e9 \u8364? price\tab tab\line next\par
}";
let i = import(rtf).unwrap();
let st = &i.story;
st.check().unwrap();
assert_eq!(st.text, "Hello bold and italic plain\nCafé € price\ttab\u{2028}next");
let at = st.text.find("bold").unwrap();
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
assert_eq!(st.format_after(st.text.find("italic").unwrap()).over.font_style.as_deref(), Some("Italic"));
assert_eq!(st.format_after(st.text.find("plain").unwrap()).over.font_style, None);
assert_eq!(st.format_after(0).over.size, Some(12.0));
assert!(!st.text.contains("Times"), "font table skipped");
}
}