S: style groups (style.group), renames reach table cells and footnotes; Package report preflight fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 16:36:29 -04:00
parent fed23aa7ee
commit 08727dfd7e
6 changed files with 219 additions and 38 deletions

View File

@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- 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`).
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase are still to come.

View File

@ -713,6 +713,19 @@ impl crate::Document {
}
impl Story {
/// Visit this story and every text inside it (table cells, footnotes), mutably.
pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) {
f(self);
for t in self.tables.values_mut() {
for c in &mut std::sync::Arc::make_mut(t).cells {
c.text.for_each_text_mut(f);
}
}
for n in &mut self.notes {
std::sync::Arc::make_mut(n).text.for_each_text_mut(f);
}
}
/// The table whose anchor paragraph is `pi`.
pub fn para_table(&self, pi: usize) -> Option<&Arc<Table>> {
self.tables.get(&self.paras.get(pi)?.table?)

View File

@ -111,7 +111,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
for l in links.as_array().into_iter().flatten() {
r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or(""));
}
if let Some(issues) = pre.as_array() {
if let Some(issues) = pre.get("issues").and_then(Value::as_array) {
r += &format!("\nPreflight: {} issue(s)\n", issues.len());
for i in issues {
r += &format!(" {}\n", i.get("message").and_then(Value::as_str).unwrap_or(""));
@ -159,6 +159,7 @@ mod tests {
assert!(out.join("Links").join("logo.svg").exists());
let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap();
assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}");
assert!(report.contains("Preflight:"), "{report}");
// The packaged document links to its Links folder.
let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap();
let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap();

View File

@ -60,6 +60,15 @@ pub fn specs() -> Vec<CommandSpec> {
super::has_selection,
apply_gradient
),
cmd!(
"style.group",
"Move to Group",
[],
None,
"{kind: paragraph|character, names: [style names], group: \"Heads\" | \"Heads/Display\" | \"\" (out of any group)} — styles are named Group/Name; every use is renamed → {renamed: {old: new}}",
has_doc,
group_styles
),
cmd!(
"swatch.load",
"Load Swatches…",
@ -238,18 +247,90 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(n) = rename.clone() {
st.name = n.clone();
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
for f in &mut story.paras {
if f.style == name {
f.style = n.clone();
}
}
// Every use: other styles, stories, table cells, footnotes, object styles.
rename_style(d, true, &name, &n);
}
ok()
})
}
/// Rename a paragraph or character style everywhere it's used.
fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
if para {
for s in &mut st.paragraph {
if s.name == from {
s.name = to.to_string();
}
for r in [&mut s.based_on, &mut s.next_style] {
if r.as_deref() == Some(from) {
*r = Some(to.to_string());
}
}
}
ok()
for o in &mut st.object {
if o.paragraph_style.as_deref() == Some(from) {
o.paragraph_style = Some(to.to_string());
}
}
} else {
for s in &mut st.character {
if s.name == from {
s.name = to.to_string();
}
if s.based_on.as_deref() == Some(from) {
s.based_on = Some(to.to_string());
}
}
}
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
let Some(story) = d.story_mut(sid) else { continue };
story.for_each_text_mut(&mut |st| {
if para {
for f in st.paras.iter_mut().filter(|f| f.style == from) {
f.style = to.to_string();
}
} else {
for r in st.chars.iter_mut().filter(|r| r.format.style == from) {
r.format.style = to.to_string();
}
}
st.rev += 1;
});
}
}
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
let para = match str_param(p, "kind").unwrap_or("paragraph") {
"paragraph" => true,
"character" => false,
k => return Err(bad("style.group", format!("unknown kind `{k}`"))),
};
let names: Vec<String> =
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string();
s.edit(|d, _| {
let mut renamed = serde_json::Map::new();
for n in &names {
if n.starts_with('[') {
return Err(bad("style.group", format!("`{n}` is built in")));
}
let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) };
if !exists {
return Err(bad("style.group", format!("no style `{n}`")));
}
let base = n.rsplit('/').next().unwrap_or(n);
let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") };
let taken = |d: &designcraft_doc::Document, x: &str| {
if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) }
};
if to != *n {
to = Styles::unique_name(|x| taken(d, x), &to);
rename_style(d, para, n, &to);
renamed.insert(n.clone(), json!(to));
}
}
Ok(json!({"renamed": renamed}))
})
}
@ -643,6 +724,27 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
mod color_tests {
use super::*;
#[test]
fn style_groups_rename_every_use() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap();
let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap();
assert_eq!(r["renamed"]["Head"], "Titles/Head");
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head");
assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head"));
// Out of the group again.
s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap();
assert!(s.doc().unwrap().doc.styles.para("Head").is_some());
assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err());
}
#[test]
fn swatches_save_and_load_ase() {
let mut s = Session::new();
@ -686,3 +788,21 @@ mod color_tests {
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
}
}
#[cfg(test)]
mod group_sample_tests {
use serde_json::json;
use crate::Session;
/// Grouping the sample's table styles keeps its table cells styled (no missing styles).
#[test]
fn grouping_reaches_table_cells() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap();
s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap();
let after = s.execute("preflight.run", &json!({})).unwrap();
assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}");
}
}

View File

@ -25,33 +25,23 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) {
.size(11.0)
.color(t.text_dim),
);
for n in &names {
// Groups (`Group/Name`) as folders, ungrouped styles first.
let groups: Vec<String> = {
let mut g: Vec<String> = names.iter().filter_map(|n| n.rsplit_once('/').map(|(g, _)| g.to_string())).collect();
g.sort();
g.dedup();
g
};
for n in names.iter().filter(|n| !n.contains('/')) {
if para && n == designcraft_doc::NO_PARA_STYLE {
continue;
}
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if current.as_deref() == Some(n.as_str()) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
ui.painter().text(row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text);
if resp.double_clicked() && para {
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n})));
} else if resp.clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let clear = ui.input(|i| i.modifiers.alt);
let _ = app.run(cmd, json!({"name": n, "clearOverrides": clear}));
}
resp.context_menu(|ui| {
if ui.button(format!("Apply \"{n}\"")).clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let _ = app.run(cmd, json!({"name": n}));
ui.close();
}
if current.as_deref() == Some(n.as_str()) && ui.button("Break Link to Style").clicked() {
let _ = app.run("style.breakLink", json!({"kind": if para { "paragraph" } else { "character" }}));
ui.close();
style_row(app, ui, para, n, current.as_deref(), &groups, &t);
}
for g in &groups {
egui::CollapsingHeader::new(g.as_str()).id_salt(("style_group", para, g)).default_open(true).show(ui, |ui| {
for n in names.iter().filter(|n| n.rsplit_once('/').is_some_and(|(x, _)| x == g)) {
style_row(app, ui, para, n, current.as_deref(), &groups, &t);
}
});
}
@ -75,3 +65,59 @@ pub fn paragraph(app: &mut DesignApp, ui: &mut egui::Ui) {
pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) {
list(app, ui, false);
}
/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph),
/// right-click: apply, break link, move to group.
fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) {
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if current == Some(n) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let shown = n.rsplit('/').next().unwrap_or(n);
ui.painter().text(row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text);
if resp.double_clicked() && para {
app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n})));
} else if resp.clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let clear = ui.input(|i| i.modifiers.alt);
let _ = app.run(cmd, json!({"name": n, "clearOverrides": clear}));
}
let kind = if para { "paragraph" } else { "character" };
resp.context_menu(|ui| {
if ui.button(format!("Apply \"{shown}\"")).clicked() {
let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" };
let _ = app.run(cmd, json!({"name": n}));
ui.close();
}
if current == Some(n) && ui.button("Break Link to Style").clicked() {
let _ = app.run("style.breakLink", json!({"kind": kind}));
ui.close();
}
if !n.starts_with('[') {
ui.menu_button("Move to Group", |ui| {
if n.contains('/') && ui.button("[No Group]").clicked() {
let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""}));
ui.close();
}
for g in groups {
if n.rsplit_once('/').map(|(x, _)| x) != Some(g.as_str()) && ui.button(g).clicked() {
let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": g}));
ui.close();
}
}
ui.separator();
let id = egui::Id::new(("new_style_group", para));
let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default();
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text("New group…").desired_width(140.0));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !name.trim().is_empty() {
let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": name.trim()}));
name.clear();
ui.close();
}
ui.data_mut(|d| d.insert_temp(id, name));
});
}
});
}

View File

@ -5,12 +5,12 @@ 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: 77%** over 275 features.
**Weighted breadth parity: 78%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 54 | 24 | 19 | 68% |
| P1 | 97 | 55 | 24 | 18 | 69% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
@ -25,7 +25,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Placing & links | 78% |
| Type & text frames | 82% |
| Typography | 83% |
| Styles | 71% |
| Styles | 82% |
| Tables | 73% |
| Long documents | 58% |
| Interactivity & digital | 30% |
@ -240,7 +240,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Styles | Character styles | P0 | D |
| Styles | Object styles | P1 | D |
| Styles | Table & cell styles | P1 | M |
| Styles | Style groups | P1 | M |
| Styles | Style groups | P1 | D |
| Styles | Quick Apply | P1 | D |
| Styles | Break link to style | P1 | D |
| Styles | Style mapping on import | P1 | P |