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

412 lines
16 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 ");
// The title stays one outline paragraph (a `\par` would start another slide on re-import),
// so its paragraphs are joined with line breaks.
let mut tps = title.into_iter().flat_map(|t| &t.paragraphs).filter(|p| !p.is_empty()).peekable();
if tps.peek().is_none() {
escape(&mut out, &s.title());
}
for (k, tp) in tps.enumerate() {
if k > 0 {
out.push_str("\\line ");
}
runs(&mut out, tp);
}
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",
// A list item's rendered bullet or number, written before its text (Word: listtext; RTF 1.5 and
// older writers: pntext, pntxta, pntxtb). Its `\pard` must not reset the paragraph's indent.
"listtext",
"pntext",
"pntxta",
"pntxtb",
];
/// `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).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).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'_' => line.push('\u{2011}'),
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::*;
/// A sign in a `\'hh` escape is not a hex digit and must be ignored.
#[test]
fn hex_escape_rejects_sign() {
assert_eq!(rtf_to_outline(r"{\rtf1 a\'+5b\par}"), "ab\n");
}
/// 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");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
assert_eq!(rtf_to_outline(r"{\rtf1\pard Year\_end review\par}"), "Year\u{2011}end review\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));
}
/// A title with two paragraphs: the plain outline keeps both, the RTF export kept only the first.
#[test]
fn every_title_paragraph_is_exported() {
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
crate::outline_to_slides(&mut p, "First line\n\tBody\n");
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
let title = slide.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_mut()).unwrap();
title.paragraphs.push(Paragraph::new("Second line"));
let rtf = slides_to_rtf(&p);
assert!(rtf.contains("First line\\line Second line\\b0\\par"), "{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");
}
/// Word writes a list item's bullet as `{\\listtext\\pard\\plain …\\'b7\\tab}` (older writers:
/// `{\\pntext …}`) after the paragraph's own `\\li`. That group is the rendered bullet, not text.
#[test]
fn word_list_bullets_are_not_text_and_keep_the_indent() {
let rtf = r"{\rtf1\ansi\pard Title\par\pard\fi-360\li720{\listtext\pard\plain\f1\'b7\tab}Point\par\pard\li720{\pntext\f1\'b7\tab}Old\par}";
assert_eq!(rtf_to_outline(rtf), "Title\n\t\tPoint\n\t\tOld\n");
}
}