Files
designcraft/crates/textimport/src/rtf.rs
T
Hamza 7808816e9a import: a value a file gives is one the engine can mean (#1593)
The importers each had their own spelling of "is this a number we can use", or
only checked that it was finite. Measured before the change:

    <cSize:1e300>  -> Some(1e300) pt           tagged text
    <cSize:-5>     -> Some(-5.0) pt
    <cSize:0>      -> Some(0.0) pt
    w:spacing before=1e300 -> 5e298 pt indent  DOCX

DC-12 asks for a shared clamp helper used by every importer, so there are two
in `doc::attrs`, beside the tab limits:

* `imported_type_size` drops a size the engine cannot mean, so the text keeps
  the size its style gives it -- which is what RTF and DOCX already did.
* `imported_measure` clamps an indent, spacing or offset to the widest page.
  Clamped rather than dropped on purpose: a file that says "indent everything"
  still indents as far as the engine goes, where dropping would quietly restore
  the style's own indent and hide what the file asked for.

Both are now used by tagged text, DOCX runs and paragraphs, DOCX numbering
levels and RTF -- the last two had the 0.1-1296 pt range as a literal, which is
the duplication that let tagged text drift.

Control: with the helpers back to a finite-only test, both new tests fail, the
DOCX one printing the 5e298 pt spacing in full.

Stale in the issue: its first location, `docx.rs` `w:sz`, is already clamped --
that came with #1574. DC-12's remaining holes are the ones fixed here.
2026-10-24 16:17:49 +05:00

378 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
// A group is not a character: the `\uN` fallback skip ends here too.
pending = 0;
// `{\*\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).
i += 2;
if pending > 0 {
pending -= 1;
continue;
}
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");
}
_ => {}
}
}
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;
// A control word is one character of the `\uN` fallback (a following `\u` is not).
if pending > 0 && word != "u" {
pending -= 1;
continue;
}
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).and_then(designcraft_doc::attrs::imported_type_size);
}),
"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 {
// RTF allows -32768..=65535; anything else is dropped.
if (-32768..=65535).contains(&n) {
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 out_of_range_unicode_escape_is_dropped() {
assert_eq!(import(br"{\rtf1 a\u4294967361?b}").unwrap().story.text, "ab");
assert_eq!(import(br"{\rtf1 a\u-4294967231?b}").unwrap().story.text, "ab");
}
#[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");
}
/// A control word or control symbol is one character of the `\uN` fallback, so it is skipped
/// like a plain character and does not eat the text after it.
#[test]
fn control_fallback_after_unicode_is_skipped() {
let i = import(br"{\rtf1 \u8220\ldblquote Hi\u8221\rdblquote x\u8212\emdash y}").unwrap();
assert_eq!(i.story.text, "\u{201C}Hi\u{201D} x\u{2014}y");
let i = import(br"{\rtf1 \u8220\{Hi}").unwrap();
assert_eq!(i.story.text, "\u{201C}Hi");
let i = import(br"{\rtf1 \u8220{\b Hi}}").unwrap();
assert_eq!(i.story.text, "\u{201C}Hi");
}
/// 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");
}
}