H: place text files — Word (.docx), RTF, plain text; autoflow
- designcraft-textimport (L3): DOCX (styles by name with spacing/indents/alignment and font, size, bold/italic/underline/strike/caps/position; runs, tabs, breaks, footnotes, tables, hyperlink/field results; used styles only), RTF (text, paragraphs, bold/italic/underline/ strike, size, Unicode/cp1252, skipped destinations), plain text (+ tests) - engine: file.place routes text files into the insertion point, the selected frame or a new frame on the page; imported styles added (existing win); removeStyles; autoflow adds pages with threaded frames until the story fits (+ test) - app/web: Place dialogs accept .docx/.rtf/.txt/.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f8ccff2470
commit
446c70af04
|
|
@ -922,6 +922,7 @@ dependencies = [
|
|||
"designcraft-idml",
|
||||
"designcraft-pdf",
|
||||
"designcraft-render",
|
||||
"designcraft-textimport",
|
||||
"designcraft-tools",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
|
|
@ -1034,6 +1035,15 @@ dependencies = [
|
|||
"vello_cpu 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml 0.38.4",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ designcraft-render = { path = "crates/render" }
|
|||
designcraft-pdf = { path = "crates/pdf" }
|
||||
designcraft-idml = { path = "crates/idml" }
|
||||
designcraft-epub = { path = "crates/epub" }
|
||||
designcraft-textimport = { path = "crates/textimport" }
|
||||
designcraft-format = { path = "crates/format" }
|
||||
designcraft-tools = { path = "crates/tools" }
|
||||
designcraft-engine = { path = "crates/engine" }
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
|
||||
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
|
||||
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use designcraft_ui_egui::{DesignApp, Inbox, Services};
|
|||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const DOC_EXTS: &[&str] = &["designcraft", "idml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"];
|
||||
const CANVAS_ID: &str = "designcraft_canvas";
|
||||
const LOADING_ID: &str = "designcraft_loading";
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,9 @@ fn services() -> Services {
|
|||
pick_open: Some(Box::new(|purpose: &str| {
|
||||
let d = rfd::FileDialog::new();
|
||||
let d = if purpose == "place" {
|
||||
d.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
|
||||
d.add_filter("Graphics and text", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "txt", "docx", "rtf", "md"])
|
||||
.add_filter("Graphics", &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff"])
|
||||
.add_filter("Text (Word, RTF, plain)", &["docx", "rtf", "txt", "md"])
|
||||
} else {
|
||||
d.add_filter("DesignCraft or IDML", &["designcraft", "idml"])
|
||||
.add_filter("DesignCraft", &["designcraft"])
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ designcraft-compose = { workspace = true }
|
|||
designcraft-render = { workspace = true }
|
||||
designcraft-format = { workspace = true }
|
||||
designcraft-epub = { workspace = true }
|
||||
designcraft-textimport = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-tools = { workspace = true }
|
||||
designcraft-idml = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+D"),
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image; into the selected empty frame if any",
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?}",
|
||||
has_doc,
|
||||
file_place
|
||||
),
|
||||
|
|
@ -199,6 +199,10 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) });
|
||||
return ok();
|
||||
};
|
||||
// Text files (plain, Word, RTF) flow into frames.
|
||||
if designcraft_textimport::is_text_file(&name) {
|
||||
return super::place_text::place_text(s, p, &name, &bytes);
|
||||
}
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?;
|
||||
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
|
||||
let (nw, nh) = (pw as f64, ph as f64);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod layout;
|
|||
mod links;
|
||||
mod notes;
|
||||
mod object;
|
||||
mod place_text;
|
||||
pub mod preflight;
|
||||
pub mod spelling;
|
||||
mod style;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
//! File › Place of text files (plain text, Word, RTF): into the text insertion point, the
|
||||
//! selected frame, or a new frame on the page; with autoflow, pages and threaded frames are added
|
||||
//! until the whole story fits (InDesign's Shift-click with a loaded text cursor).
|
||||
|
||||
use designcraft_doc::{CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextSel};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Add the imported styles the document doesn't have yet (existing styles win, as in InDesign's
|
||||
/// default style-conflict option).
|
||||
fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported) {
|
||||
let st = d.styles_mut();
|
||||
for s in &imp.para_styles {
|
||||
if st.para(&s.name).is_none() {
|
||||
st.paragraph.push(ParagraphStyle {
|
||||
name: s.name.clone(),
|
||||
based_on: s.based_on.clone().or_else(|| Some(designcraft_doc::story::BASIC_PARAGRAPH.into())),
|
||||
next_style: None,
|
||||
para: s.para.clone(),
|
||||
chars: s.chars.clone(),
|
||||
shortcut: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for s in &imp.char_styles {
|
||||
if !st.character.iter().any(|c| c.name == s.name) {
|
||||
st.character.push(CharacterStyle { name: s.name.clone(), based_on: s.based_on.clone(), chars: s.chars.clone(), shortcut: String::new() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Remove styles and formatting": every paragraph [Basic Paragraph], no local formatting.
|
||||
fn strip(story: &mut Story) {
|
||||
let n = story.len();
|
||||
story.format_paras(0..n, |p| *p = ParaFormat { table: p.table, ..Default::default() });
|
||||
story.format_chars(0..n, |f| *f = Default::default());
|
||||
}
|
||||
|
||||
/// Is the story overset in its frames, and how much of it do its frames show?
|
||||
fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize) {
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0);
|
||||
(cs.overset_at, shown)
|
||||
}
|
||||
|
||||
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
|
||||
fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
|
||||
let mut added = 0;
|
||||
loop {
|
||||
let (overset, shown) = fit(d, sid);
|
||||
let Some(at) = overset else { return Ok(added) };
|
||||
if added >= max_pages {
|
||||
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
|
||||
}
|
||||
let len = d.story(sid).map_or(0, |s| s.len());
|
||||
let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1);
|
||||
// Frames still needed at the rate the existing ones are filled (at least one).
|
||||
let per_frame = (shown.max(at) / frames).max(1);
|
||||
let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added);
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
|
||||
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
|
||||
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
d.insert_pages(Some(page), want, parent)?;
|
||||
let layer = d.default_layer();
|
||||
let mut prev = last;
|
||||
for _ in 0..want {
|
||||
page += 1;
|
||||
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
|
||||
let r = d.spreads[si].pages[pi].margin_rect();
|
||||
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
|
||||
d.thread(prev, fid)?;
|
||||
prev = fid;
|
||||
}
|
||||
added += want;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut imp = designcraft_textimport::import(name, bytes).map_err(|e| bad("file.place", e.to_string()))?;
|
||||
let remove = p.get("removeStyles").and_then(Value::as_bool).unwrap_or(false);
|
||||
if remove {
|
||||
strip(&mut imp.story);
|
||||
imp.para_styles.clear();
|
||||
imp.char_styles.clear();
|
||||
}
|
||||
let autoflow_on = p.get("autoflow").and_then(Value::as_bool).unwrap_or(false);
|
||||
let st = s.doc()?;
|
||||
let caret = st.selection.text.filter(|t| t.cell.is_none());
|
||||
let frame = super::id_param(p, "frame").or_else(|| {
|
||||
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Text(_))))
|
||||
});
|
||||
let page = p.get("page").and_then(Value::as_u64).map(|v| (v as usize).saturating_sub(1));
|
||||
let rect = super::rect_param(p, "rect");
|
||||
let warnings = imp.warnings.clone();
|
||||
s.edit(|d, sel| {
|
||||
add_styles(d, &imp);
|
||||
let sid = if let Some(t) = caret {
|
||||
// Into the text at the insertion point (replacing the selection).
|
||||
let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
story.delete(r.clone());
|
||||
let end = story.insert_story(r.start, &imp.story);
|
||||
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
|
||||
t.story
|
||||
} else {
|
||||
let fid = match frame {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let pg = page.unwrap_or(0).min(d.page_count().saturating_sub(1));
|
||||
let (si, pi) = d.page_loc(pg).ok_or_else(|| bad("file.place", "no such page"))?;
|
||||
let r: Rect = rect.unwrap_or_else(|| d.spreads[si].pages[pi].margin_rect());
|
||||
let layer = d.default_layer();
|
||||
d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?.0
|
||||
}
|
||||
};
|
||||
// Empty frames become text frames.
|
||||
let sid = match d.item(fid).map(|i| i.content.clone()) {
|
||||
Some(Content::Text(t)) => t.story,
|
||||
Some(_) => {
|
||||
let sid = StoryId(d.alloc());
|
||||
let mut ns = Story::new(sid);
|
||||
ns.frames = vec![fid];
|
||||
d.stories.insert(sid, std::sync::Arc::new(ns));
|
||||
if let Some(it) = d.item_mut(fid) {
|
||||
it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: Default::default() });
|
||||
}
|
||||
sid
|
||||
}
|
||||
None => return Err(bad("file.place", "no such frame")),
|
||||
};
|
||||
let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let n = story.len();
|
||||
story.delete(0..n);
|
||||
story.insert_story(0, &imp.story);
|
||||
if let Some(first) = imp.story.paras.first() {
|
||||
story.paras[0] = ParaFormat { table: story.paras[0].table, ..first.clone() };
|
||||
}
|
||||
*sel = designcraft_doc::Selection::items(vec![fid]);
|
||||
sid
|
||||
};
|
||||
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
|
||||
let overset = fit(d, sid).0.is_some();
|
||||
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn place_text_file_with_autoflow() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.txt");
|
||||
let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12);
|
||||
std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap();
|
||||
assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}");
|
||||
assert_eq!(r["overset"], false);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count());
|
||||
assert_eq!(d.story(sid).unwrap().paras.len(), 60);
|
||||
// Into the insertion point of another frame.
|
||||
let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap();
|
||||
let sid2 = fr["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap();
|
||||
let small = dir.join("small.rtf");
|
||||
std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap();
|
||||
s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone();
|
||||
assert_eq!(st.text, "Start middle end");
|
||||
assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "designcraft-textimport"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft text import: plain text, Word (.docx) and RTF into stories"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
designcraft-doc = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
//! Word (.docx, Office Open XML) → story.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE};
|
||||
use designcraft_doc::{CharAttrs, CharFormat, ParaAttrs, ParaFormat, Story, StoryId, Table};
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::{ImportError, Imported, ImportedStyle};
|
||||
|
||||
/// A minimal element tree (local names, attributes by local name).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct El {
|
||||
name: String,
|
||||
attrs: Vec<(String, String)>,
|
||||
kids: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Node {
|
||||
El(El),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl El {
|
||||
fn attr(&self, k: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
|
||||
}
|
||||
fn els(&self) -> impl Iterator<Item = &El> {
|
||||
self.kids.iter().filter_map(|n| if let Node::El(e) = n { Some(e) } else { None })
|
||||
}
|
||||
fn child(&self, name: &str) -> Option<&El> {
|
||||
self.els().find(|e| e.name == name)
|
||||
}
|
||||
fn text(&self) -> String {
|
||||
self.kids
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
Node::Text(t) => t.clone(),
|
||||
Node::El(e) => e.text(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on".
|
||||
fn val(&self, name: &str) -> Option<String> {
|
||||
self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string())
|
||||
}
|
||||
fn on(&self, name: &str) -> Option<bool> {
|
||||
self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none"))
|
||||
}
|
||||
}
|
||||
|
||||
fn local(n: &[u8]) -> String {
|
||||
let s = String::from_utf8_lossy(n);
|
||||
s.rsplit(':').next().unwrap_or(&s).to_string()
|
||||
}
|
||||
|
||||
fn parse(xml: &str) -> Result<El, ImportError> {
|
||||
let mut r = quick_xml::Reader::from_str(xml);
|
||||
let mut stack: Vec<El> = vec![El::default()];
|
||||
loop {
|
||||
match r.read_event().map_err(|e| ImportError::Corrupt(e.to_string()))? {
|
||||
Event::Start(e) => {
|
||||
let el = El {
|
||||
name: local(e.name().as_ref()),
|
||||
attrs: e
|
||||
.attributes()
|
||||
.flatten()
|
||||
.map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default()))
|
||||
.collect(),
|
||||
kids: vec![],
|
||||
};
|
||||
stack.push(el);
|
||||
}
|
||||
Event::Empty(e) => {
|
||||
let el = El {
|
||||
name: local(e.name().as_ref()),
|
||||
attrs: e
|
||||
.attributes()
|
||||
.flatten()
|
||||
.map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default()))
|
||||
.collect(),
|
||||
kids: vec![],
|
||||
};
|
||||
if let Some(p) = stack.last_mut() {
|
||||
p.kids.push(Node::El(el));
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
let el = stack.pop().unwrap_or_default();
|
||||
match stack.last_mut() {
|
||||
Some(p) => p.kids.push(Node::El(el)),
|
||||
None => return Ok(el),
|
||||
}
|
||||
}
|
||||
Event::Text(t) => {
|
||||
let s = t.decode().map(|s| s.to_string()).unwrap_or_default();
|
||||
let s = quick_xml::escape::unescape(&s).map(|s| s.to_string()).unwrap_or(s);
|
||||
if let Some(p) = stack.last_mut() {
|
||||
p.kids.push(Node::Text(s));
|
||||
}
|
||||
}
|
||||
Event::GeneralRef(e) => {
|
||||
let name = String::from_utf8_lossy(&e).to_string();
|
||||
let s = match name.as_str() {
|
||||
"amp" => "&".to_string(),
|
||||
"lt" => "<".into(),
|
||||
"gt" => ">".into(),
|
||||
"quot" => "\"".into(),
|
||||
"apos" => "'".into(),
|
||||
n if n.starts_with("#x") => u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
|
||||
n if n.starts_with('#') => n[1..].parse::<u32>().ok().and_then(char::from_u32).map(String::from).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
if let Some(p) = stack.last_mut() {
|
||||
p.kids.push(Node::Text(s));
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut root = stack.pop().unwrap_or_default();
|
||||
Ok(root.kids.drain(..).find_map(|n| if let Node::El(e) = n { Some(e) } else { None }).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn part(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, name: &str) -> Option<String> {
|
||||
let mut f = zip.by_name(name).ok()?;
|
||||
let mut s = String::new();
|
||||
f.read_to_string(&mut s).ok()?;
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// Character attributes from `<w:rPr>`.
|
||||
fn run_attrs(rpr: Option<&El>) -> CharAttrs {
|
||||
let mut a = CharAttrs::default();
|
||||
let Some(r) = rpr else { return a };
|
||||
let bold = r.on("b");
|
||||
let italic = r.on("i");
|
||||
a.font_style = match (bold, italic) {
|
||||
(Some(true), Some(true)) => Some("Bold Italic".into()),
|
||||
(Some(true), _) => Some("Bold".into()),
|
||||
(_, Some(true)) => Some("Italic".into()),
|
||||
(Some(false), Some(false)) | (Some(false), None) | (None, Some(false)) => Some("Regular".into()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(u) = r.val("u") {
|
||||
a.underline = Some(u != "none" && u != "0" && u != "false");
|
||||
}
|
||||
if let Some(s) = r.on("strike") {
|
||||
a.strikethrough = Some(s);
|
||||
}
|
||||
if let Some(sz) = r.val("sz").and_then(|v| v.parse::<f64>().ok()) {
|
||||
a.size = Some(sz / 2.0);
|
||||
}
|
||||
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
|
||||
a.font_family = Some(f.to_string());
|
||||
}
|
||||
if r.on("caps") == Some(true) {
|
||||
a.capitalization = Some(designcraft_doc::Capitalization::AllCaps);
|
||||
}
|
||||
if let Some(v) = r.val("vertAlign") {
|
||||
a.position = Some(match v.as_str() {
|
||||
"superscript" => designcraft_doc::Position::Superscript,
|
||||
"subscript" => designcraft_doc::Position::Subscript,
|
||||
_ => designcraft_doc::Position::Normal,
|
||||
});
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Paragraph attributes from `<w:pPr>` (spacing in twips = 1/20 pt, indents likewise).
|
||||
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
|
||||
let mut a = ParaAttrs::default();
|
||||
let Some(p) = ppr else { return a };
|
||||
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().ok()).map(|v| v / 20.0);
|
||||
if let Some(sp) = p.child("spacing") {
|
||||
a.space_before = tw(sp.attr("before"));
|
||||
a.space_after = tw(sp.attr("after"));
|
||||
}
|
||||
if let Some(ind) = p.child("ind") {
|
||||
a.left_indent = tw(ind.attr("left").or(ind.attr("start")));
|
||||
a.right_indent = tw(ind.attr("right").or(ind.attr("end")));
|
||||
a.first_line_indent = tw(ind.attr("firstLine")).or(tw(ind.attr("hanging")).map(|h| -h));
|
||||
}
|
||||
if let Some(j) = p.val("jc") {
|
||||
a.align = Some(match j.as_str() {
|
||||
"center" => designcraft_doc::Align::Center,
|
||||
"right" | "end" => designcraft_doc::Align::Right,
|
||||
"both" | "distribute" => designcraft_doc::Align::LeftJustified,
|
||||
_ => designcraft_doc::Align::Left,
|
||||
});
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
para_names: HashMap<String, String>,
|
||||
char_names: HashMap<String, String>,
|
||||
footnotes: HashMap<String, El>,
|
||||
warnings: Vec<String>,
|
||||
next_table: u64,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn para_style(&self, ppr: Option<&El>) -> String {
|
||||
ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.para_names.get(&id).cloned()).unwrap_or_else(|| BASIC_PARAGRAPH.into())
|
||||
}
|
||||
|
||||
/// Append the paragraphs and tables of `body` to `st`.
|
||||
fn blocks(&mut self, body: &El, st: &mut Story, allow_notes: bool) {
|
||||
let mut first = st.is_empty();
|
||||
for e in body.els() {
|
||||
match e.name.as_str() {
|
||||
"p" => {
|
||||
if !first {
|
||||
let len = st.len();
|
||||
st.insert(len, "\n");
|
||||
}
|
||||
first = false;
|
||||
let ppr = e.child("pPr");
|
||||
let style = self.para_style(ppr);
|
||||
let attrs = para_attrs(ppr);
|
||||
let at = st.len();
|
||||
st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() });
|
||||
self.inline(e, st, allow_notes);
|
||||
}
|
||||
"tbl" => {
|
||||
let rows: Vec<Vec<String>> = e
|
||||
.els()
|
||||
.filter(|r| r.name == "tr")
|
||||
.map(|r| {
|
||||
r.els()
|
||||
.filter(|c| c.name == "tc")
|
||||
.map(|c| c.els().filter(|p| p.name == "p").map(|p| p.text()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.next_table += 1;
|
||||
let t = Table::from_strings(self.next_table, &rows, 300.0, &ParaFormat::default(), &CharFormat::default());
|
||||
let len = st.len();
|
||||
st.insert_table(len, t);
|
||||
first = false;
|
||||
}
|
||||
"sdt" => {
|
||||
if let Some(c) = e.child("sdtContent") {
|
||||
self.blocks(c, st, allow_notes);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs (and hyperlinks, fields' results) of a paragraph.
|
||||
fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) {
|
||||
for e in p.els() {
|
||||
match e.name.as_str() {
|
||||
"r" => self.run(e, st, allow_notes),
|
||||
"hyperlink" | "smartTag" | "ins" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, r: &El, st: &mut Story, allow_notes: bool) {
|
||||
let rpr = r.child("rPr");
|
||||
let style = rpr.and_then(|p| p.val("rStyle")).and_then(|id| self.char_names.get(&id).cloned()).unwrap_or_else(|| NO_CHAR_STYLE.into());
|
||||
let fmt = CharFormat { style, over: run_attrs(rpr) };
|
||||
for e in r.els() {
|
||||
let text = match e.name.as_str() {
|
||||
"t" => e.text(),
|
||||
"tab" => "\t".into(),
|
||||
"br" => match e.attr("type") {
|
||||
Some("page") => designcraft_doc::PAGE_BREAK.to_string(),
|
||||
Some("column") => designcraft_doc::COLUMN_BREAK.to_string(),
|
||||
_ => designcraft_doc::FORCED_LINE_BREAK.to_string(),
|
||||
},
|
||||
"noBreakHyphen" => "\u{2011}".into(),
|
||||
"softHyphen" => "\u{AD}".into(),
|
||||
"footnoteReference" if allow_notes => {
|
||||
if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) {
|
||||
let mut ns = Story::new(StoryId(0));
|
||||
self.blocks(¬e, &mut ns, false);
|
||||
// Word puts the reference mark in the note text: we generate the number.
|
||||
let len = st.len();
|
||||
let id = st.insert_note(len, "", ParaFormat::default());
|
||||
if let Some(n) = st.note_mut(id) {
|
||||
n.text = ns;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"drawing" | "pict" | "object" => {
|
||||
if !self.warnings.iter().any(|w| w.starts_with("Images")) {
|
||||
self.warnings.push("Images and drawings in the Word file were not imported (place them separately).".into());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if !text.is_empty() {
|
||||
let len = st.len();
|
||||
st.insert_with(len, &text, fmt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?;
|
||||
let doc = part(&mut zip, "word/document.xml").ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?;
|
||||
let doc = parse(&doc)?;
|
||||
// Styles.
|
||||
let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 };
|
||||
let mut para_styles = Vec::new();
|
||||
let mut char_styles = Vec::new();
|
||||
if let Some(styles) = part(&mut zip, "word/styles.xml").map(|s| parse(&s)).transpose()? {
|
||||
let ids: HashMap<String, String> =
|
||||
styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect();
|
||||
for s in styles.els().filter(|s| s.name == "style") {
|
||||
let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue };
|
||||
let kind = s.attr("type").unwrap_or("paragraph");
|
||||
// Word's Normal / default paragraph font are our [Basic Paragraph] / [None].
|
||||
let name = match (kind, name.as_str()) {
|
||||
("paragraph", "Normal") => BASIC_PARAGRAPH.to_string(),
|
||||
("character", "Default Paragraph Font") => NO_CHAR_STYLE.to_string(),
|
||||
_ => name.clone(),
|
||||
};
|
||||
let based_on = s.val("basedOn").and_then(|b| ids.get(&b).cloned()).map(|b| if b == "Normal" { BASIC_PARAGRAPH.to_string() } else { b });
|
||||
let imp = ImportedStyle { name: name.clone(), based_on, para: para_attrs(s.child("pPr")), chars: run_attrs(s.child("rPr")) };
|
||||
match kind {
|
||||
"paragraph" => {
|
||||
ctx.para_names.insert(id.to_string(), name.clone());
|
||||
if name != BASIC_PARAGRAPH {
|
||||
para_styles.push(imp);
|
||||
}
|
||||
}
|
||||
"character" => {
|
||||
ctx.char_names.insert(id.to_string(), name.clone());
|
||||
if name != NO_CHAR_STYLE {
|
||||
char_styles.push(imp);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Footnotes (ids -1 / 0 are the separators).
|
||||
if let Some(f) = part(&mut zip, "word/footnotes.xml").map(|s| parse(&s)).transpose()? {
|
||||
for n in f.els().filter(|n| n.name == "footnote") {
|
||||
if let Some(id) = n.attr("id").filter(|id| id.parse::<i64>().is_ok_and(|v| v > 0)) {
|
||||
ctx.footnotes.insert(id.to_string(), n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?;
|
||||
let mut st = Story::new(StoryId(0));
|
||||
ctx.blocks(body, &mut st, true);
|
||||
// Only styles that are used, plus what they are based on.
|
||||
let used_p: std::collections::HashSet<String> = st.paras.iter().map(|p| p.style.clone()).collect();
|
||||
let used_c: std::collections::HashSet<String> = st.runs().map(|(_, f)| f.style.clone()).collect();
|
||||
let keep = |list: Vec<ImportedStyle>, used: &std::collections::HashSet<String>| {
|
||||
let mut want: std::collections::HashSet<String> = used.clone();
|
||||
loop {
|
||||
let more: Vec<String> =
|
||||
list.iter().filter(|s| want.contains(&s.name)).filter_map(|s| s.based_on.clone()).filter(|b| !want.contains(b)).collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
want.extend(more);
|
||||
}
|
||||
list.into_iter().filter(|s| want.contains(&s.name)).collect::<Vec<_>>()
|
||||
};
|
||||
Ok(Imported { story: st, para_styles: keep(para_styles, &used_p), char_styles: keep(char_styles, &used_c), warnings: ctx.warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec<u8> {
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (n, c) in [("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)] {
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
w.start_file(n, o).unwrap();
|
||||
w.write_all(c.as_bytes()).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
|
||||
#[test]
|
||||
fn imports_styles_runs_footnotes_and_tables() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W}>
|
||||
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Unused"><w:name w:val="Unused"/></w:style>
|
||||
<w:style w:type="character" w:styleId="Emph"><w:name w:val="Emphasis"/><w:rPr><w:i/></w:rPr></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let document = format!(
|
||||
r#"<w:document {W}><w:body>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Title & more</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t xml:space="preserve">Plain </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>bold</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="Emph"/></w:rPr><w:t> styled</w:t></w:r><w:r><w:tab/><w:t>x</w:t></w:r>
|
||||
<w:r><w:footnoteReference w:id="1"/></w:r></w:p>
|
||||
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:r><w:t>After</w:t></w:r></w:p>
|
||||
</w:body></w:document>"#
|
||||
);
|
||||
let notes = format!(
|
||||
r#"<w:footnotes {W}><w:footnote w:id="0"><w:p/></w:footnote>
|
||||
<w:footnote w:id="1"><w:p><w:r><w:t>The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
|
||||
);
|
||||
let i = import(&docx(&document, &styles, ¬es)).unwrap();
|
||||
let st = &i.story;
|
||||
st.check().unwrap();
|
||||
assert!(st.text.starts_with("Title & more\nPlain bold styled\tx"), "{:?}", st.text);
|
||||
assert!(st.text.ends_with("After"));
|
||||
assert_eq!(st.paras[0].style, "heading 1");
|
||||
assert_eq!(st.paras[1].style, BASIC_PARAGRAPH);
|
||||
let at = st.text.find("bold").unwrap();
|
||||
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
|
||||
assert_eq!(st.format_after(st.text.find("styled").unwrap()).style, "Emphasis");
|
||||
assert_eq!(st.notes.len(), 1);
|
||||
assert_eq!(st.notes[0].text.text, "The note.");
|
||||
assert_eq!(st.tables.len(), 1);
|
||||
let names: Vec<&str> = i.para_styles.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["heading 1"], "unused styles are skipped");
|
||||
assert_eq!(i.para_styles[0].chars.size, Some(16.0));
|
||||
assert_eq!(i.para_styles[0].para.space_before, Some(12.0));
|
||||
assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
//! Text import (File › Place of text files): plain text, Word documents (`.docx`) and RTF become
|
||||
//! a frameless [`Story`] with paragraph and character styles, local bold/italic/underline/size,
|
||||
//! footnotes (Word) and tables (Word), ready to flow into text frames.
|
||||
//!
|
||||
//! Word styles are imported by name (their font, size, bold/italic and space before/after become
|
||||
//! the style definition, like InDesign's "Use Word styles" import option). RTF keeps bold,
|
||||
//! italic, underline, size and paragraph breaks.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod docx;
|
||||
mod rtf;
|
||||
|
||||
use designcraft_doc::{CharAttrs, ParaAttrs, Story};
|
||||
|
||||
/// A paragraph or character style found in the source.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ImportedStyle {
|
||||
pub name: String,
|
||||
pub based_on: Option<String>,
|
||||
pub para: ParaAttrs,
|
||||
pub chars: CharAttrs,
|
||||
}
|
||||
|
||||
/// The imported text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub story: Story,
|
||||
pub para_styles: Vec<ImportedStyle>,
|
||||
pub char_styles: Vec<ImportedStyle>,
|
||||
/// Anything the import couldn't keep (images, fields, …).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ImportError {
|
||||
Unsupported(String),
|
||||
Corrupt(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ImportError::Unsupported(s) => write!(f, "unsupported text file: {s}"),
|
||||
ImportError::Corrupt(s) => write!(f, "can't read the file: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text files this crate reads, by extension.
|
||||
pub fn is_text_file(name: &str) -> bool {
|
||||
let l = name.to_ascii_lowercase();
|
||||
l.ends_with(".txt") || l.ends_with(".text") || l.ends_with(".md") || l.ends_with(".docx") || l.ends_with(".rtf")
|
||||
}
|
||||
|
||||
/// Import `bytes` (format from the file name, else sniffed).
|
||||
pub fn import(name: &str, bytes: &[u8]) -> Result<Imported, ImportError> {
|
||||
let l = name.to_ascii_lowercase();
|
||||
if l.ends_with(".docx") || bytes.starts_with(b"PK\x03\x04") {
|
||||
return docx::import(bytes);
|
||||
}
|
||||
if l.ends_with(".rtf") || bytes.starts_with(b"{\\rtf") {
|
||||
return rtf::import(bytes);
|
||||
}
|
||||
if l.ends_with(".doc") {
|
||||
return Err(ImportError::Unsupported("legacy Word .doc (save it as .docx)".into()));
|
||||
}
|
||||
Ok(plain(&String::from_utf8_lossy(bytes)))
|
||||
}
|
||||
|
||||
/// Plain text: line breaks become paragraphs (CRLF / CR normalised).
|
||||
pub fn plain(text: &str) -> Imported {
|
||||
let t = text.trim_start_matches('\u{FEFF}').replace("\r\n", "\n").replace('\r', "\n");
|
||||
let story = Story::with_text(designcraft_doc::StoryId(0), &t, Default::default());
|
||||
Imported { story, para_styles: vec![], char_styles: vec![], warnings: vec![] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_text_paragraphs() {
|
||||
let i = import("a.txt", b"\xEF\xBB\xBFOne\r\nTwo\rThree").unwrap();
|
||||
assert_eq!(i.story.text, "One\nTwo\nThree");
|
||||
assert_eq!(i.story.paras.len(), 3);
|
||||
assert!(is_text_file("Report.DOCX") && !is_text_file("a.png"));
|
||||
assert!(matches!(import("x.doc", b"\xD0\xCF"), Err(ImportError::Unsupported(_))));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
//! 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.
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SKIP: &[&str] = &[
|
||||
"fonttbl",
|
||||
"colortbl",
|
||||
"stylesheet",
|
||||
"info",
|
||||
"pict",
|
||||
"header",
|
||||
"footer",
|
||||
"footnote",
|
||||
"listtable",
|
||||
"listoverridetable",
|
||||
"rsidtbl",
|
||||
"generator",
|
||||
"themedata",
|
||||
"colorschememapping",
|
||||
"latentstyles",
|
||||
"datastore",
|
||||
"xmlnstbl",
|
||||
"fldinst",
|
||||
"object",
|
||||
];
|
||||
|
||||
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 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;
|
||||
// `{\*\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();
|
||||
}
|
||||
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.
|
||||
let hex: String = chars.iter().skip(i + 2).take(2).collect();
|
||||
i += 4;
|
||||
if pending > 0 {
|
||||
pending -= 1;
|
||||
continue;
|
||||
}
|
||||
if let Ok(b) = u8::from_str_radix(&hex, 16)
|
||||
&& !stack.last().is_some_and(|s| s.skip)
|
||||
{
|
||||
buf.push(cp1252(b));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !next.is_ascii_alphabetic() {
|
||||
// Control symbols: \\ \{ \} \~ (nbsp) \- (soft hyphen) \_ (nb hyphen).
|
||||
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");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// A control word with an optional numeric parameter and one delimiting space.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && chars[j].is_ascii_alphabetic() {
|
||||
j += 1;
|
||||
}
|
||||
let word: String = chars[i + 1..j].iter().collect();
|
||||
let mut k = j;
|
||||
if k < chars.len() && (chars[k] == '-' || chars[k].is_ascii_digit()) {
|
||||
k += 1;
|
||||
while k < chars.len() && chars[k].is_ascii_digit() {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let param: Option<i64> = chars[j..k].iter().collect::<String>().parse().ok();
|
||||
if k < chars.len() && chars[k] == ' ' {
|
||||
k += 1;
|
||||
}
|
||||
i = k;
|
||||
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),
|
||||
"fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)),
|
||||
"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 {
|
||||
let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 };
|
||||
if let Some(ch) = char::from_u32(cp) {
|
||||
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) {
|
||||
buf.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn imports_rtf_text_and_formatting() {
|
||||
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times;}}{\colortbl;\red0\green0\blue0;}{\*\generator Test;}
|
||||
\f0\fs24 Hello {\b bold} and {\i italic\i0 plain}\par
|
||||
Caf\'e9 \u8364? price\tab tab\line next\par
|
||||
}";
|
||||
let i = import(rtf).unwrap();
|
||||
let st = &i.story;
|
||||
st.check().unwrap();
|
||||
assert_eq!(st.text, "Hello bold and italic plain\nCafé € price\ttab\u{2028}next");
|
||||
let at = st.text.find("bold").unwrap();
|
||||
assert_eq!(st.format_after(at).over.font_style.as_deref(), Some("Bold"));
|
||||
assert_eq!(st.format_after(st.text.find("italic").unwrap()).over.font_style.as_deref(), Some("Italic"));
|
||||
assert_eq!(st.format_after(st.text.find("plain").unwrap()).over.font_style, None);
|
||||
assert_eq!(st.format_after(0).over.size, Some(12.0));
|
||||
assert!(!st.text.contains("Times"), "font table skipped");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,24 +5,24 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
|
|||
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 63%** over 275 features.
|
||||
**Weighted breadth parity: 66%** over 275 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 79 | 18 | 5 | 86% |
|
||||
| P1 | 97 | 31 | 24 | 42 | 44% |
|
||||
| P0 | 102 | 87 | 12 | 3 | 91% |
|
||||
| P1 | 97 | 32 | 24 | 41 | 45% |
|
||||
| P2 | 76 | 8 | 7 | 61 | 15% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell & workspace | 57% |
|
||||
| Application shell & workspace | 65% |
|
||||
| Documents, pages, spreads | 66% |
|
||||
| Layers | 74% |
|
||||
| Frames, shapes & paths | 70% |
|
||||
| Frames, shapes & paths | 74% |
|
||||
| Transform | 56% |
|
||||
| Fill, stroke, colour | 46% |
|
||||
| Fill, stroke, colour | 62% |
|
||||
| Effects & transparency | 50% |
|
||||
| Placing & links | 49% |
|
||||
| Placing & links | 53% |
|
||||
| Type & text frames | 77% |
|
||||
| Typography | 76% |
|
||||
| Styles | 55% |
|
||||
|
|
@ -32,10 +32,10 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
|||
| Output & production | 48% |
|
||||
| XML & automation | 39% |
|
||||
| View & navigation | 62% |
|
||||
| Undo, history, saving | 67% |
|
||||
| Undo, history, saving | 100% |
|
||||
| Accessibility | 20% |
|
||||
|
||||
Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup, Parent item overrides, Polygon settings, Pen, add/delete anchor, convert direction, Paste Into / Paste in Place, Scale strokes / content options, Dimensions Include Stroke Weight, Color panel, Color Picker dialog, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Text import (TXT/DOCX/RTF), Display performance, OpenType features, Export to PDF (print), Display performance, Revert, Save / Save As / Save a Copy.
|
||||
Open P0 items: Dockable panels, Preferences, Document Setup, Parent item overrides, Pen, add/delete anchor, convert direction, Scale strokes / content options, Dimensions Include Stroke Weight, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance.
|
||||
|
||||
## Rows
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup
|
|||
| Placing & links | Raster formats | P0 | P |
|
||||
| Placing & links | Vector/page formats (PDF/AI/SVG/EPS place) | P0 | M |
|
||||
| Placing & links | Placing INDD/IDML pages | P2 | M |
|
||||
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | P |
|
||||
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | D |
|
||||
| Placing & links | Links panel | P0 | D |
|
||||
| Placing & links | Effective/actual PPI | P0 | D |
|
||||
| Placing & links | Relink to folder / extension | P1 | M |
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
|||
("pdf", Class::Layer(3)),
|
||||
("idml", Class::Layer(3)),
|
||||
("epub", Class::Layer(3)),
|
||||
("textimport", Class::Layer(3)),
|
||||
("format", Class::Layer(3)),
|
||||
("tools", Class::Layer(4)),
|
||||
("engine", Class::Layer(5)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue