Delegate RTF parsing and writing to craft-libs

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 00:28:33 -05:00
1 parent 9889488f5d
commit 6ff34b922f
5 files changed
+137 -409

No files matched your search

Generated
+15
View File
@@ -1017,6 +1017,11 @@ dependencies = [
"rayon",
]
[[package]]
name = "craft-codepage"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
[[package]]
name = "craft-dialogs"
version = "0.1.0"
@@ -1150,6 +1155,15 @@ dependencies = [
"winsafe",
]
[[package]]
name = "craft-rtf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
dependencies = [
"craft-codepage",
"encoding_rs",
]
[[package]]
name = "craft-svg"
version = "0.1.0"
@@ -1663,6 +1677,7 @@ dependencies = [
name = "designcraft-textimport"
version = "0.20.1"
dependencies = [
"craft-rtf",
"designcraft-doc",
"quick-xml 0.41.0",
"zip 3.0.0",
+1
View File
@@ -19,6 +19,7 @@ craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "3afade516
craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog.
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] }
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
+1
View File
@@ -10,5 +10,6 @@ workspace = true
[dependencies]
designcraft-doc = { workspace = true }
craft-rtf = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
+56 -111
View File
@@ -5,8 +5,6 @@
//! become `#`. RTF also keeps fonts, size, bold/italic, underline, strikethrough,
//! superscript/subscript, alignment, indents and paragraph spacing.
use std::fmt::Write;
use designcraft_doc::story::{FORCED_LINE_BREAK, NEXT_PAGE_NUMBER, PAGE_NUMBER, PREV_PAGE_NUMBER, TABLE_ANCHOR};
use designcraft_doc::{Align, Document, Position, Story};
@@ -59,50 +57,32 @@ pub fn plain_text(story: &Story) -> String {
out
}
fn rtf_escape(out: &mut String, c: char) {
match c {
'\\' | '{' | '}' => {
out.push('\\');
out.push(c);
}
'\t' => out.push_str("\\tab "),
FORCED_LINE_BREAK => out.push_str("\\line "),
c if (c as u32) < 0x80 => out.push(c),
c => {
// \uN with a `?` fallback; N is a signed 16-bit value, astral characters as surrogates.
let mut buf = [0u16; 2];
for u in c.encode_utf16(&mut buf) {
let _ = write!(out, "\\u{}?", *u as i16);
}
}
}
}
fn twips(pt: f64) -> i64 {
(pt * 20.0).round() as i64
fn twips(pt: f64) -> i32 {
(pt * 20.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32
}
/// Rich Text Format, with the story's resolved formatting.
pub fn rtf(doc: &Document, story: &Story) -> String {
let styles = &doc.styles;
// Resolve every run first so the font table can be written up front.
let ranges = story.para_ranges();
// The story's runs once; each paragraph takes those reaching it by binary search.
let all_runs: Vec<_> = story.runs().collect();
let mut paras = Vec::with_capacity(ranges.len());
let mut fonts: Vec<String> = Vec::new();
// Each font's number in the table (`fonts` keeps the table's order).
let mut font_index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut add_font = |fonts: &mut Vec<String>, name: &str| {
if !font_index.contains_key(name) {
font_index.insert(name.to_string(), fonts.len());
fonts.push(name.to_string());
}
};
for (i, r) in ranges.iter().enumerate() {
let mut neutral = craft_rtf::Document::default();
for (i, r) in story.para_ranges().into_iter().enumerate() {
let pf = story.paras.get(i).cloned().unwrap_or_default();
let (pp, base) = styles.resolve_para(&pf);
let mut runs = Vec::new();
let mut para = craft_rtf::Para {
left_indent: twips(pp.left_indent),
right_indent: twips(pp.right_indent),
first_line_indent: twips(pp.first_line_indent),
space_before: twips(pp.space_before),
space_after: twips(pp.space_after),
align: Some(match pp.align {
Align::Center => craft_rtf::Align::Center,
Align::Right => craft_rtf::Align::Right,
Align::Left => craft_rtf::Align::Left,
_ => craft_rtf::Align::Justified,
}),
..Default::default()
};
let first = all_runs.partition_point(|(rr, _)| rr.end <= r.start);
for (rr, f) in all_runs.iter().skip(first).take_while(|(rr, _)| rr.start < r.end) {
let (a, b) = (rr.start.max(r.start), rr.end.min(r.end));
@@ -110,90 +90,55 @@ pub fn rtf(doc: &Document, story: &Story) -> String {
continue;
}
let cp = styles.resolve_char(&base, f);
// Hidden conditional text isn't exported.
if doc.conditions_hide(&cp.conditions) || cp.change == designcraft_doc::ChangeMark::Deleted {
continue;
}
add_font(&mut fonts, &cp.font_family);
runs.push((a..b, cp));
}
if runs.is_empty() {
add_font(&mut fonts, &base.font_family);
}
paras.push((pp, base, pf.table, runs));
}
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\uc1\\deff0\n{\\fonttbl");
for (i, f) in fonts.iter().enumerate() {
let _ = write!(out, "{{\\f{i}\\fnil ");
for c in f.chars() {
rtf_escape(&mut out, c);
}
out.push_str(";}");
}
out.push_str("}\n");
let font_index: std::collections::HashMap<&str, usize> = fonts.iter().enumerate().map(|(i, f)| (f.as_str(), i)).collect();
for (pp, base, table, runs) in &paras {
let q = match pp.align {
Align::Center => "\\qc",
Align::Right => "\\qr",
Align::Left => "\\ql",
_ => "\\qj",
};
let _ = write!(
out,
"\\pard{q}\\li{}\\ri{}\\fi{}\\sb{}\\sa{}",
twips(pp.left_indent),
twips(pp.right_indent),
twips(pp.first_line_indent),
twips(pp.space_before),
twips(pp.space_after)
);
if runs.is_empty() {
let f = font_index.get(base.font_family.as_str()).copied().unwrap_or(0);
let _ = write!(out, "\\plain\\f{f}\\fs{} ", (base.size * 2.0).round() as i64);
}
for (r, cp) in runs {
let f = font_index.get(cp.font_family.as_str()).copied().unwrap_or(0);
let st = cp.font_style.to_ascii_lowercase();
let _ = write!(out, "{{\\plain\\f{f}\\fs{}", (cp.size * 2.0).round() as i64);
if st.contains("bold") || st.contains("black") || st.contains("heavy") || st.contains("semibold") {
out.push_str("\\b");
}
if st.contains("italic") || st.contains("oblique") {
out.push_str("\\i");
}
if cp.underline {
out.push_str("\\ul");
}
if cp.strikethrough {
out.push_str("\\strike");
}
match cp.position {
Position::Superscript | Position::OtSuperscript => out.push_str("\\super"),
Position::Subscript | Position::OtSubscript => out.push_str("\\sub"),
_ => {}
}
out.push(' ');
for c in story.text[r.clone()].chars() {
let style = cp.font_style.to_ascii_lowercase();
let half_points = (cp.size * 2.0).round();
let fmt = craft_rtf::CharFmt {
bold: style.contains("bold") || style.contains("black") || style.contains("heavy") || style.contains("semibold"),
italic: style.contains("italic") || style.contains("oblique"),
underline: cp.underline,
strike: cp.strikethrough,
vert_align: match cp.position {
Position::Superscript | Position::OtSuperscript => craft_rtf::VertAlign::Superscript,
Position::Subscript | Position::OtSubscript => craft_rtf::VertAlign::Subscript,
_ => craft_rtf::VertAlign::Baseline,
},
half_points: (half_points.is_finite() && (1.0..=25_000.0).contains(&half_points)).then_some(half_points as u32),
color: None,
font: Some(cp.font_family.clone()),
};
let mut text = String::new();
for c in story.text[a..b].chars() {
match c {
TABLE_ANCHOR => {
if let Some(id) = table {
for ch in table_text(story, *id, "\u{2028}").chars() {
rtf_escape(&mut out, ch);
}
if let Some(id) = pf.table {
text.push_str(&table_text(story, id, "
"));
}
}
PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => out.push('#'),
'\u{E000}'..='\u{E1FF}' => {}
c => rtf_escape(&mut out, c),
PAGE_NUMBER | NEXT_PAGE_NUMBER | PREV_PAGE_NUMBER => text.push('#'),
''..='' => {}
c => text.push(c),
}
}
out.push('}');
if !text.is_empty() {
para.runs.push(craft_rtf::Run { text, fmt });
}
}
out.push_str("\\par\n");
if para.runs.is_empty() {
para.runs.push(craft_rtf::Run {
text: String::new(),
fmt: craft_rtf::CharFmt {
half_points: Some((base.size * 2.0).round().clamp(1.0, 25_000.0) as u32),
font: Some(base.font_family),
..Default::default()
},
});
}
neutral.blocks.push(craft_rtf::Block::Para(para));
}
out.push('}');
out
craft_rtf::write(&neutral, "Source Serif 4")
}
#[cfg(test)]
+64 -298
View File
@@ -1,315 +1,81 @@
//! 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.
//! Adapter from craft-rtf's neutral document model to a DesignCraft story.
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,
fn char_format(fmt: &craft_rtf::CharFmt) -> CharFormat {
let font_style = match (fmt.bold, fmt.italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
};
let mut over = CharAttrs {
font_style: (font_style != "Regular").then(|| font_style.to_string()),
underline: fmt.underline.then_some(true),
strikethrough: fmt.strike.then_some(true),
size: fmt.half_points.and_then(|size| designcraft_doc::attrs::imported_type_size(f64::from(size) / 2.0)),
..Default::default()
};
if let Some(font) = &fmt.font {
over.font_family = Some(font.clone());
}
CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over }
}
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()
},
fn append_para(story: &mut Story, para: &craft_rtf::Para, separate: bool) {
if separate {
let at = story.len();
story.insert_with(at, "\n", CharFormat::default());
}
if let Some(attrs) = story.paras.last_mut().map(|format| &mut format.para) {
attrs.left_indent = Some(f64::from(para.left_indent) / 20.0);
attrs.right_indent = Some(f64::from(para.right_indent) / 20.0);
attrs.first_line_indent = Some(f64::from(para.first_line_indent) / 20.0);
attrs.space_before = Some(f64::from(para.space_before) / 20.0);
attrs.space_after = Some(f64::from(para.space_after) / 20.0);
attrs.align = Some(match para.align {
Some(craft_rtf::Align::Left) | None => designcraft_doc::attrs::Align::Left,
Some(craft_rtf::Align::Center) => designcraft_doc::attrs::Align::Center,
Some(craft_rtf::Align::Right) => designcraft_doc::attrs::Align::Right,
Some(craft_rtf::Align::Justified) => designcraft_doc::attrs::Align::FullyJustified,
});
}
for run in &para.runs {
let mut text = String::with_capacity(run.text.len());
for c in run.text.chars() {
text.push(match c {
craft_rtf::LINE_BREAK => designcraft_doc::FORCED_LINE_BREAK,
craft_rtf::PAGE_BREAK => designcraft_doc::PAGE_BREAK,
craft_rtf::COLUMN_BREAK => designcraft_doc::COLUMN_BREAK,
other => other,
});
}
if !text.is_empty() {
let at = story.len();
story.insert_with(at, &text, char_format(&run.fmt));
}
}
}
const SKIP: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"info",
"pict",
"header",
"headerl",
"headerr",
"headerf",
"footer",
"footerl",
"footerr",
"footerf",
"footnote",
"listtable",
"listoverridetable",
"rsidtbl",
"generator",
"themedata",
"colorschememapping",
"latentstyles",
"datastore",
"xmlnstbl",
"fldinst",
"object",
];
struct ControlWord {
word: String,
parameter: Option<i64>,
next_index: usize,
}
fn read_control_word(chars: &[char], index: usize) -> ControlWord {
let mut word_end = index + 1;
while word_end < chars.len() && chars[word_end].is_ascii_alphabetic() {
word_end += 1;
}
let word = chars[index + 1..word_end].iter().collect();
let mut next_index = word_end;
// A '-' only starts the parameter when a digit follows; otherwise it is text.
let negative = chars.get(next_index) == Some(&'-') && chars.get(next_index + 1).is_some_and(char::is_ascii_digit);
let has_parameter = negative || chars.get(next_index).is_some_and(char::is_ascii_digit);
if has_parameter {
next_index += 1;
while next_index < chars.len() && chars[next_index].is_ascii_digit() {
next_index += 1;
}
}
let parameter = chars[word_end..next_index].iter().collect::<String>().parse().ok();
if chars.get(next_index) == Some(&' ') {
next_index += 1;
}
ControlWord { word, parameter, next_index }
}
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.
// Only valid hex digits are consumed; anything else is left for the main loop.
let hex: String = chars.iter().skip(i + 2).take(2).take_while(|c| c.is_ascii_hexdigit()).collect();
i += 2 + hex.len();
if pending > 0 {
pending -= 1;
continue;
}
if hex.len() == 2
&& 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 ControlWord { word, parameter: param, next_index } = read_control_word(&chars, i);
i = next_index;
// 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);
}
}
let doc = craft_rtf::read(bytes).map_err(|_| ImportError::Corrupt("not an RTF file".into()))?;
let mut story = Story::new(StoryId(0));
let mut paragraph_count = 0;
for block in &doc.blocks {
let paras: Vec<&craft_rtf::Para> = match block {
craft_rtf::Block::Para(para) => vec![para],
craft_rtf::Block::Table(table) => table.rows.iter().flatten().flat_map(|cell| &cell.paras).collect(),
};
for para in paras {
append_para(&mut story, para, paragraph_count > 0);
paragraph_count += 1;
}
}
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 }
story.end_paragraphs_at_breaks();
let warnings = if doc.pictures_left_out > 0 { vec!["Pictures in the RTF file were not imported.".into()] } else { vec![] };
Ok(Imported { story, para_styles: vec![], char_styles: vec![], warnings })
}
#[cfg(test)]