Files
deckcraft/crates/format/src/rtf.rs
T

364 lines
14 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.
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
//!
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
//! the tab-indented outline [`crate::outline_to_slides`] understands.
use deckcraft_model::Presentation;
use deckcraft_model::text::{Paragraph, RunKind};
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
fn escape(out: &mut String, s: &str) {
for c in s.chars() {
match c {
'\\' | '{' | '}' => {
out.push('\\');
out.push(c);
}
'\t' => out.push_str("\\tab "),
'\u{b}' | '\n' => out.push_str("\\line "),
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
c if c.is_ascii() => {}
c => {
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
out.push_str(&format!("\\u{}?", *u as i16));
}
}
}
}
}
fn runs(out: &mut String, p: &Paragraph) {
for r in &p.runs {
if matches!(r.kind, RunKind::Break) {
out.push_str("\\line ");
continue;
}
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
if !(b || i || u) {
escape(out, &r.text);
continue;
}
out.push('{');
out.push_str(if b { "\\b" } else { "" });
out.push_str(if i { "\\i" } else { "" });
out.push_str(if u { "\\ul" } else { "" });
out.push(' ');
escape(out, &r.text);
out.push('}');
}
}
/// The presentation's outline (slide titles and body text) as RTF.
pub fn slides_to_rtf(p: &Presentation) -> String {
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
for s in &p.slides {
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
Some(tp) => runs(&mut out, tp),
None => escape(&mut out, &s.title()),
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
let Some(t) = &sh.text else { continue };
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
let lvl = para.level.min(8) as u32;
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
runs(&mut out, para);
out.push_str("\\par\n");
}
}
}
out.push_str("}\n");
out
}
/// Whether `bytes` look like RTF.
pub fn sniff_rtf(bytes: &[u8]) -> bool {
bytes.trim_ascii_start().starts_with(b"{\\rtf")
}
/// Destinations whose text is not document text.
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"footer",
"footnote",
"listtable",
"listoverridetable",
"generator",
"xmlnstbl",
"themedata",
"datastore",
"latentstyles",
"rsidtbl",
"object",
"fldinst",
];
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
/// run after `\uN` or `\'hh` may be followed by non-ASCII text).
fn advance(rtf: &str, i: usize, n: usize) -> usize {
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
}
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
/// indent in 0.25" steps) becomes its number of tabs.
pub fn rtf_to_outline(rtf: &str) -> String {
#[derive(Clone, Copy)]
struct St {
skip: bool,
uc: usize,
}
let b = rtf.as_bytes();
let mut stack: Vec<St> = vec![];
let mut st = St { skip: false, uc: 1 };
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
let mut line = String::new();
let mut out = String::new();
let mut pending_skip = 0usize;
let mut i = 0;
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
let text = line.trim();
if !text.is_empty() {
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
out.push_str(&"\t".repeat(n));
out.push_str(text);
out.push('\n');
}
line.clear();
};
while i < b.len() {
let c = b[i];
match c {
b'{' => {
stack.push(st);
i += 1;
// `{\*\dest …}`: an ignorable destination.
if b[i..].starts_with(b"\\*") {
st.skip = true;
}
}
b'}' => {
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
i += 1;
}
b'\\' => {
i += 1;
let Some(&n) = b.get(i) else { break };
if n.is_ascii_alphabetic() {
let s = i;
while i < b.len() && b[i].is_ascii_alphabetic() {
i += 1;
}
let word = &rtf[s..i];
let ns = i;
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
let arg: Option<i64> = rtf[ns..i].parse().ok();
if i < b.len() && b[i] == b' ' {
i += 1;
}
if pending_skip > 0 {
pending_skip -= 1;
continue;
}
if SKIP.contains(&word) {
st.skip = true;
}
if st.skip {
continue;
}
match word {
"par" => {
flush(&mut line, level, indent, &mut out);
}
"pard" => {
level = None;
indent = 0;
}
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
"li" => indent = arg.unwrap_or(0),
"tab" | "line" => line.push(' '),
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
"u" => {
let v = arg.unwrap_or(0);
// RTF writes UTF-16 units as signed 16-bit values.
push_utf16(&mut line, (v & 0xffff) as u32);
pending_skip = 0;
// Skip the fallback characters.
let mut k = st.uc;
while k > 0 && i < b.len() {
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
i = advance(rtf, i, 4);
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
break;
} else {
i = advance(rtf, i, 1);
}
k -= 1;
}
}
"emdash" => line.push('—'),
"endash" => line.push('–'),
"bullet" => line.push('•'),
"lquote" => line.push('‘'),
"rquote" => line.push('’'),
"ldblquote" => line.push('“'),
"rdblquote" => line.push('”'),
_ => {}
}
} else if n == b'\'' {
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
i = advance(rtf, i, 3);
if !st.skip
&& let Some(h) = hex
&& h != 0
{
line.push(cp1252(h));
}
} else {
i += 1;
if st.skip {
continue;
}
match n {
b'\\' | b'{' | b'}' => line.push(n as char),
b'~' => line.push('\u{a0}'),
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
_ => {}
}
}
}
b'\r' | b'\n' => i += 1,
_ => {
let s = i;
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
i += 1;
}
if !st.skip
&& let Some(t) = rtf.get(s..i)
{
line.push_str(&t.replace('\u{0}', ""));
}
}
}
}
flush(&mut line, level, indent, &mut out);
finish_surrogates(out)
}
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
/// and joined afterwards. NUL itself is the marker, so a NUL unit is dropped.
fn push_utf16(line: &mut String, v: u32) {
if v == 0 {
return;
}
match char::from_u32(v) {
Some(c) => line.push(c),
None => line.push_str(&format!("\u{0}{v:04x}")),
}
}
fn finish_surrogates(s: String) -> String {
if !s.contains('\u{0}') {
return s;
}
let mut out = String::new();
let mut rest = s.as_str();
while let Some(p) = rest.find('\u{0}') {
out.push_str(&rest[..p]);
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
match (hi, lo) {
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
rest = &rest[p + 10..];
}
_ => {
out.push('\u{fffd}');
// Skip the NUL and, when four hex digits follow, the digits; otherwise
// resume right after the NUL so no following character is lost.
let skip = if hi.is_some() { p + 5 } else { p + 1 };
rest = rest.get(skip..).unwrap_or("");
}
}
}
out.push_str(rest);
out
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9f => HIGH[(b - 0x80) as usize],
_ => b as char,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
#[test]
fn non_ascii_text_after_escapes_does_not_panic() {
let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}");
assert!(out.contains('x'), "{out:?}");
for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] {
let _ = rtf_to_outline(rtf);
}
}
/// NUL is the surrogate marker, so NULs from `\u0`, `\'00` or raw bytes must not reach `finish_surrogates`.
#[test]
fn nul_escapes_do_not_drop_text_or_add_a_slide() {
assert_eq!(rtf_to_outline(r"{\rtf1 \u0?abcé\par}"), "abcé\n");
assert_eq!(rtf_to_outline(r"{\rtf1 a\'00bé\par}"), "abé\n");
assert_eq!(rtf_to_outline("{\\rtf1 a\0b\\par}\0\0"), "ab\n");
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
#[test]
fn huge_left_indent_gives_the_deepest_level() {
let out = rtf_to_outline(r"{\rtf1\pard\li9223372036854775807 deep\par\pard\li-9223372036854775808 flat\par}");
assert_eq!(out, format!("{}deep\nflat\n", "\t".repeat(9)));
}
#[test]
fn export_reads_back_as_the_same_outline() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
crate::outline_to_slides(&mut p, outline);
let rtf = slides_to_rtf(&p);
assert!(sniff_rtf(rtf.as_bytes()));
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
}
#[test]
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
}
}