From 446c70af04fb6590a48c27524bdbd7cda9178d7e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:55:28 -0400 Subject: [PATCH] =?UTF-8?q?H:=20place=20text=20files=20=E2=80=94=20Word=20?= =?UTF-8?q?(.docx),=20RTF,=20plain=20text;=20autoflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 10 + Cargo.toml | 1 + ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 2 +- apps/designcraft/src/main.rs | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 6 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/place_text.rs | 182 ++++++++++++ crates/textimport/Cargo.toml | 14 + crates/textimport/src/docx.rs | 446 ++++++++++++++++++++++++++++ crates/textimport/src/lib.rs | 89 ++++++ crates/textimport/src/rtf.rs | 290 ++++++++++++++++++ docs/parity.md | 20 +- xtask/src/layers.rs | 1 + 15 files changed, 1055 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/cmd/place_text.rs create mode 100644 crates/textimport/Cargo.toml create mode 100644 crates/textimport/src/docx.rs create mode 100644 crates/textimport/src/lib.rs create mode 100644 crates/textimport/src/rtf.rs diff --git a/Cargo.lock b/Cargo.lock index ea1b2fa..60b62d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 50356bb..e670e85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/ROADMAP.md b/ROADMAP.md index da8d6d8..52b6989 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index acd26d0..d17617b 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -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"; diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 3e5fcc6..feb425f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -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"]) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ce62947..c9e4809 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 17663f3..bcab61a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "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 { 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); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7d08b75..271c54d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ mod layout; mod links; mod notes; mod object; +mod place_text; pub mod preflight; pub mod spelling; mod style; diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs new file mode 100644 index 0000000..634705a --- /dev/null +++ b/crates/engine/src/cmd/place_text.rs @@ -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) { + 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 { + 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 { + 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); + } +} diff --git a/crates/textimport/Cargo.toml b/crates/textimport/Cargo.toml new file mode 100644 index 0000000..bbbd57f --- /dev/null +++ b/crates/textimport/Cargo.toml @@ -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 } diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs new file mode 100644 index 0000000..a7f2139 --- /dev/null +++ b/crates/textimport/src/docx.rs @@ -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, +} + +#[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 { + 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() + } + /// `` → the value; a bare `` counts as "on". + fn val(&self, name: &str) -> Option { + self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string()) + } + fn on(&self, name: &str) -> Option { + 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 { + let mut r = quick_xml::Reader::from_str(xml); + let mut stack: Vec = 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::().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>, name: &str) -> Option { + 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 ``. +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::().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 `` (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::().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, + char_names: HashMap, + footnotes: HashMap, + warnings: Vec, + 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> = 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::>().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 { + 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 = + 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::().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 = st.paras.iter().map(|p| p.style.clone()).collect(); + let used_c: std::collections::HashSet = st.runs().map(|(_, f)| f.style.clone()).collect(); + let keep = |list: Vec, used: &std::collections::HashSet| { + let mut want: std::collections::HashSet = used.clone(); + loop { + let more: Vec = + 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::>() + }; + 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 { + 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#" + + + + + + "# + ); + let document = format!( + r#" + Title & more + Plain bold + styledx + + AB + After + "# + ); + let notes = format!( + r#" + The note."# + ); + 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")); + } +} diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs new file mode 100644 index 0000000..7cc5aa6 --- /dev/null +++ b/crates/textimport/src/lib.rs @@ -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, + pub para: ParaAttrs, + pub chars: CharAttrs, +} + +/// The imported text. +#[derive(Clone, Debug)] +pub struct Imported { + pub story: Story, + pub para_styles: Vec, + pub char_styles: Vec, + /// Anything the import couldn't keep (images, fields, …). + pub warnings: Vec, +} + +#[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 { + 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(_)))); + } +} diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs new file mode 100644 index 0000000..c02e5e5 --- /dev/null +++ b/crates/textimport/src/rtf.rs @@ -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, + /// 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 { + 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 = 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 = 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 = chars[j..k].iter().collect::().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"); + } +} diff --git a/docs/parity.md b/docs/parity.md index 051344c..a0233ed 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. -**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 | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 2a154dd..3e9e28c 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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)),