mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
Found while testing crates/textimport/src/rtf.rs:219 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
346 lines
14 KiB
Rust
346 lines
14 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 units = crate::Utf16Units::default();
|
||
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();
|
||
}
|
||
// The `\uN` fallback skip ends with the group.
|
||
pending = 0;
|
||
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)
|
||
{
|
||
units.reset();
|
||
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;
|
||
// A '-' only starts the parameter when a digit follows; otherwise it is text.
|
||
let negative = chars.get(k) == Some(&'-') && chars.get(k + 1).is_some_and(char::is_ascii_digit);
|
||
if negative || chars.get(k).is_some_and(char::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),
|
||
// Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default.
|
||
"fs" => restyle(&|s| {
|
||
s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt));
|
||
}),
|
||
"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 unit = if n < 0 { (n + 65536) as u32 } else { n as u32 };
|
||
if let Some(ch) = units.push(unit) {
|
||
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) {
|
||
units.reset();
|
||
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);
|
||
}
|
||
// A page or column break ends its paragraph.
|
||
st.end_paragraphs_at_breaks();
|
||
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");
|
||
}
|
||
|
||
/// `\fs` sizes outside the engine's 0.1–1296 pt character size range are not stored.
|
||
#[test]
|
||
fn font_size_out_of_range_is_not_stored() {
|
||
let i = import(br"{\rtf1\ansi\fs0 A\fs-8 B\fs999999999 C\fs24 D}").unwrap();
|
||
let st = &i.story;
|
||
for ch in ["A", "B", "C"] {
|
||
let size = st.format_after(st.text.find(ch).unwrap()).over.size;
|
||
assert!(size.is_none_or(|s| (0.1..=1296.0).contains(&s)), "{ch}: {size:?}");
|
||
}
|
||
assert_eq!(st.format_after(st.text.find('D').unwrap()).over.size, Some(12.0));
|
||
}
|
||
|
||
/// A '-' right after a control word is text unless a digit follows it (then it is a negative
|
||
/// parameter). It used to be swallowed.
|
||
#[test]
|
||
fn hyphen_after_control_word_without_digits_is_text() {
|
||
let i = import(br"{\rtf1\ansi Rock\emdash-roll, x\tab-y, a\li-720 b}").unwrap();
|
||
assert_eq!(i.story.text, "Rock\u{2014}-roll, x\t-y, ab");
|
||
}
|
||
|
||
/// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form
|
||
/// our own exporter and Word write). Each half was converted alone and dropped.
|
||
#[test]
|
||
fn surrogate_pairs_become_one_character() {
|
||
let i = import(br"{\rtf1\ansi\uc1 A\u-10179?\u-8704?B \u-10174?\u-8265?\u30000?}").unwrap();
|
||
assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}");
|
||
// What the exporter writes comes back.
|
||
let mut st = Story::new(StoryId(1));
|
||
st.insert(0, "x \u{1F600} \u{20BB7}\u{7530} y");
|
||
let doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
|
||
assert_eq!(import(crate::export::rtf(&doc, &st).as_bytes()).unwrap().story.text, st.text);
|
||
// Half a pair is not a character: it is still left out.
|
||
assert_eq!(import(br"{\rtf1 A\u-10179?B\u-8704?C}").unwrap().story.text, "ABC");
|
||
}
|
||
|
||
/// A `\uN` at the end of a group has no fallback character inside it. The skip count must
|
||
/// end with the group, not eat the text after it.
|
||
#[test]
|
||
fn unicode_fallback_skip_ends_with_group() {
|
||
assert_eq!(import(br"{\rtf1 {\b \u8364} AB}").unwrap().story.text, "\u{20AC} AB");
|
||
assert_eq!(import(br"{\rtf1 {\uc2\b \u8364} ABC}").unwrap().story.text, "\u{20AC} ABC");
|
||
}
|
||
}
|