mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-07 10:55:52 +00:00
Adds PowerPoint's Outline/RTF save type: slide titles at outline level 0 (bold, larger), body paragraphs indented at their outline level, bold, italic and underline kept, non-ASCII as \u escapes. RTF outlines (ours, PowerPoint's or a word processor's, by outline level or indent) open as a presentation or drop in as slides. Co-authored-by: Cursor <cursoragent@cursor.com>
322 lines
12 KiB
Rust
322 lines
12 KiB
Rust
//! 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",
|
||
];
|
||
|
||
/// 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 + 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);
|
||
let v = if v < 0 { v + 65536 } else { v } as u32;
|
||
push_utf16(&mut line, v);
|
||
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 += 4;
|
||
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
|
||
break;
|
||
} else {
|
||
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 += 3;
|
||
if !st.skip
|
||
&& let Some(h) = hex
|
||
{
|
||
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 {
|
||
line.push_str(&rtf[s..i]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
flush(&mut line, level, indent, &mut out);
|
||
finish_surrogates(out)
|
||
}
|
||
|
||
/// Push a UTF-16 unit; surrogate halves are parked as private markers and joined afterwards.
|
||
fn push_utf16(line: &mut String, v: u32) {
|
||
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}');
|
||
rest = rest.get(p + 5..).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::*;
|
||
|
||
#[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");
|
||
}
|
||
}
|