mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
Delegate RTF parsing and writing to craft-libs
This commit is contained in:
5 files changed
+137
-409
No files matched your search
Generated
+15
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,5 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
designcraft-doc = { workspace = true }
|
||||
craft-rtf = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
+56
-111
@@ -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 ¶s {
|
||||
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
@@ -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 ¶.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)]
|
||||
|
||||
Reference in new issue
Block a user