I: Word/RTF import options — style mapping and conflict handling (+ dialog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5ec65bed9
commit
615b033f47
|
|
@ -947,6 +947,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -53,6 +53,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}),
|
||||
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::<Vec<_>>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::<Vec<_>>(), "conflicts": conflicts}))
|
||||
}),
|
||||
cmd!(
|
||||
"snippet.place",
|
||||
"Place Snippet",
|
||||
|
|
@ -77,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<String, String>, conflict: Conflict) {
|
||||
let mut rename: std::collections::HashMap<String, String> = 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<String, String> = 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<u8> {
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
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="Title"><w:name w:val="Title"/><w:rPr><w:sz w:val="56"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Body"><w:name w:val="Body"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<w:document {W}><w:body><w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Head</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Body"/></w:pPr><w:r><w:t>Text</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
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::<Vec<_>>(), ["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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<String> = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default();
|
||||
let imported: Vec<String> = 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<Value, String> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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<Result<Value,
|
|||
}
|
||||
"app.openDialog" => 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()) {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue