diff --git a/Cargo.lock b/Cargo.lock index f85a1f9..8b7857c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,6 +947,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 330d2f3..385f64c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,6 +64,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. +- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c9e4809..a598e8c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,6 +28,7 @@ log = { workspace = true } regex = { workspace = true } [dev-dependencies] +zip = { workspace = true } hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ebeba77..f2e2707 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -53,6 +53,21 @@ pub fn specs() -> Vec { }), cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), + cmd!(query "place.styles", "Import Options: Styles", [], None, + "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", + has_doc, |s, p| { + let (bytes, name, _) = read_source(p)?; + let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; + let st = &s.doc()?.doc.styles; + let conflicts: Vec<&str> = imp + .para_styles + .iter() + .filter(|x| st.para(&x.name).is_some()) + .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) + .map(|x| x.name.as_str()) + .collect(); + Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) + }), cmd!( "snippet.place", "Place Snippet", @@ -77,7 +92,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) 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?}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) 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?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", has_doc, file_place ), diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index f967339..1c78497 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -11,8 +11,72 @@ 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) { +/// How imported styles that share a name with the document's are handled (Word Import Options). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Conflict { + /// Use the document's definition. + UseExisting, + /// The imported definition replaces the document's. + Redefine, + /// Import under a new name (`Name_wrd_1`). + AutoRename, +} + +/// Map imported style names (`styleMap`) and resolve name clashes in the import before it lands. +fn map_styles(d: &Document, imp: &mut designcraft_textimport::Imported, map: &std::collections::HashMap, conflict: Conflict) { + let mut rename: std::collections::HashMap = map.clone(); + if conflict == Conflict::AutoRename { + let st = &d.styles; + for s in imp.para_styles.iter().filter(|s| !map.contains_key(&s.name) && st.para(&s.name).is_some()) { + let n = (1..).map(|i| format!("{}_wrd_{i}", s.name)).find(|n| st.para(n).is_none()).expect("free name"); + rename.insert(s.name.clone(), n); + } + for s in imp.char_styles.iter().filter(|s| !map.contains_key(&s.name) && st.char_style(&s.name).is_some()) { + let n = (1..).map(|i| format!("{}_wrd_{i}", s.name)).find(|n| st.char_style(n).is_none()).expect("free name"); + rename.insert(s.name.clone(), n); + } + } + if rename.is_empty() { + return; + } + // Mapped styles aren't imported; renamed ones come in under their new name. + imp.para_styles.retain(|s| !map.contains_key(&s.name)); + imp.char_styles.retain(|s| !map.contains_key(&s.name)); + for s in imp.para_styles.iter_mut().chain(imp.char_styles.iter_mut()) { + if let Some(n) = rename.get(&s.name) { + s.name = n.clone(); + } + if let Some(b) = s.based_on.as_ref().and_then(|b| rename.get(b)) { + s.based_on = Some(b.clone()); + } + } + let n = imp.story.len(); + imp.story.format_paras(0..n, |p| { + if let Some(m) = rename.get(&p.style) { + p.style = m.clone(); + } + }); + imp.story.format_chars(0..n, |f| { + if let Some(m) = rename.get(&f.style) { + f.style = m.clone(); + } + }); +} + +fn add_styles(d: &mut Document, imp: &designcraft_textimport::Imported, conflict: Conflict) { let st = d.styles_mut(); + if conflict == Conflict::Redefine { + for s in &imp.para_styles { + if let Some(ps) = st.paragraph.iter_mut().find(|p| p.name == s.name) { + (ps.para, ps.chars) = (s.para.clone(), s.chars.clone()); + } + } + for s in &imp.char_styles { + if let Some(cs) = st.character.iter_mut().find(|c| c.name == s.name) { + cs.chars = s.chars.clone(); + } + } + } for s in &imp.para_styles { if st.para(&s.name).is_none() { st.paragraph.push(ParagraphStyle { @@ -86,6 +150,18 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - imp.para_styles.clear(); imp.char_styles.clear(); } + let conflict = match p.get("styleConflicts").and_then(Value::as_str).unwrap_or("useExisting") { + "useExisting" => Conflict::UseExisting, + "redefine" => Conflict::Redefine, + "autoRename" => Conflict::AutoRename, + c => return Err(bad("file.place", format!("unknown styleConflicts `{c}` (useExisting, redefine, autoRename)"))), + }; + let map: std::collections::HashMap = p + .get("styleMap") + .and_then(Value::as_object) + .map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string()))).collect()) + .unwrap_or_default(); + map_styles(&s.doc()?.doc, &mut imp, &map, conflict); 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()); @@ -96,7 +172,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - let rect = super::rect_param(p, "rect"); let warnings = imp.warnings.clone(); s.edit(|d, sel| { - add_styles(d, &imp); + add_styles(d, &imp, conflict); 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))?; @@ -325,4 +401,48 @@ mod pdf_page_tests { assert!(String::from_utf8_lossy(&pdf).contains("/Subtype /Form") || String::from_utf8_lossy(&pdf).contains("/Subtype/Form")); let _ = std::fs::remove_dir_all(&dir); } + + fn word_file() -> Vec { + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let styles = format!( + r#" + + "# + ); + let doc = format!( + r#"Head + Text"# + ); + 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", doc), ("word/styles.xml", styles)] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + w.finish().unwrap().into_inner() + } + + #[test] + fn word_import_options_map_and_rename_styles() { + let b64 = crate::cmd::base64_encode(&word_file()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 9}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Headline", "chars": {"size": 30}})).unwrap(); + let info = s.execute("place.styles", &json!({"base64": b64, "name": "a.docx"})).unwrap(); + assert_eq!(info["conflicts"], json!(["Body"])); + // Title → the document's Headline; Body clashes → imported as Body_wrd_1. + let r = s + .execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleMap": {"Title": "Headline"}, "styleConflicts": "autoRename"})) + .unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(r["story"].as_u64().unwrap())).unwrap(); + assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::>(), ["Headline", "Body_wrd_1"]); + assert!(d.styles.para("Title").is_none(), "mapped styles aren't imported"); + assert_eq!(d.styles.para("Body").unwrap().chars.size, Some(9.0), "the document's Body is untouched"); + // Redefine: the imported definition replaces the document's. + s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "styleConflicts": "redefine", "rect": [72, 400, 300, 500]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0)); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dceae1c..4c37631 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -642,6 +642,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "polygonSettings" => "Polygon Settings", "userDictionary" => "User Dictionary", "newWorkspace" => "New Workspace", + "importOptions" => "Import Options", "fittingOptions" => "Frame Fitting Options", "qrCode" => "Generate QR Code", "preferences" => "Preferences", @@ -851,6 +852,50 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), "userDictionary" => user_dictionary(app, ui, &mut d), + "importOptions" => { + ui.label(egui::RichText::new(d.s("path")).size(10.5)); + check(ui, &mut d, "removeStyles", "Remove Styles and Formatting from Text and Tables"); + if !d.b("removeStyles") { + ui.label(egui::RichText::new("Style Name Conflicts").font(semibold(12.0))); + let conflicts: Vec = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + ui.label(if conflicts.is_empty() { "No conflicts".to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) }); + let cur = d.s("styleConflicts"); + ui.horizontal(|ui| { + for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] { + if ui.radio(cur == v, l).clicked() { + d.fields.insert("styleConflicts".into(), json!(v)); + } + } + }); + ui.label(egui::RichText::new("Style Mapping").font(semibold(12.0))); + let names: Vec = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + let imported: Vec = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { + for w in &imported { + ui.label(w); + let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; + egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(shown).width(170.0).show_ui(ui, |ui| { + if ui.selectable_label(mapped.is_empty(), "(import)").clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.remove(w); + } + for n in &names { + if ui.selectable_label(*n == mapped, n).clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.insert(w.clone(), json!(n)); + } + } + }); + ui.end_row(); + } + }); + }); + } + } "newWorkspace" => { ui.horizontal(|ui| { ui.label("Name:"); @@ -1200,6 +1245,10 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), + "importOptions" => app.run( + "file.place", + json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}), + ), "fittingOptions" => { let m = |k: &str| d.fields.get(k).and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()).unwrap_or(0.0); app.run( diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a2bbbcc..b3ce21b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -23,6 +23,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"), ("app.userDictionary", "User Dictionary…", None, "{} — hyphenation exceptions (hyphenation.* commands)"), ("app.fittingOptions", "Frame Fitting Options…", None, "{} — the dialog for object.fittingOptions"), + ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{} — Word/RTF style mapping before placing"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), @@ -108,6 +109,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:file.revert", "-", "ui:app.placeDialog", + "ui:app.placeWithOptions", "-", "ui:app.exportPdf", "ui:app.packageDialog", @@ -534,6 +536,23 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("open"), "app.placeDialog" => app.pick_and_open("place"), + "app.placeWithOptions" => { + // Word/RTF Import Options: style mapping and conflicts before the text lands. + let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) }; + let l = path.to_lowercase(); + if !(l.ends_with(".docx") || l.ends_with(".rtf")) { + return Some(app.run("file.place", json!({"path": path}))); + } + let info = match app.run("place.styles", json!({"path": path})) { + Ok(v) => v, + Err(e) => return Some(Err(e)), + }; + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "importOptions", + json!({"path": path, "styles": info["paragraph"], "charStyles": info["character"], "conflicts": info["conflicts"], "styleConflicts": "useExisting", "removeStyles": false, "map": {}}), + )); + Ok(Value::Null) + } "app.save" | "app.saveDialog" if app.services.download.is_some() => download_document(app), "app.save" => { if app.session.active().is_some_and(|d| d.path.is_some()) { diff --git a/docs/parity.md b/docs/parity.md index 5a2d1a8..a4e2cb1 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 86 | 10 | 1 | 94% | +| P1 | 97 | 87 | 9 | 1 | 94% | | P2 | 76 | 23 | 13 | 40 | 39% | | Area | Parity | @@ -25,7 +25,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Placing & links | 82% | | Type & text frames | 94% | | Typography | 94% | -| Styles | 92% | +| Styles | 97% | | Tables | 92% | | Long documents | 88% | | Interactivity & digital | 65% | @@ -243,7 +243,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Styles | Style groups | P1 | D | | Styles | Quick Apply | P1 | D | | Styles | Break link to style | P1 | D | -| Styles | Style mapping on import | P1 | P | +| Styles | Style mapping on import | P1 | D | | Styles | Style export tagging | P2 | P | | Tables | Create / Insert Table | P1 | D | | Tables | Convert Text to Table / Table to Text | P1 | D |