From 571e1043f73deed5914241a9dcfaaa0c4ccccb14 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 14:25:12 -0400 Subject: [PATCH] O: Transform Again family, Clear Transformations, layer merge/unused/others, Break Link to Style Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 111 ++++++++++++++++++++++++++++ crates/engine/src/cmd/object.rs | 97 ++++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 72 ++++++++++++++++++ crates/engine/src/lib.rs | 30 ++++++++ crates/engine/src/tooling.rs | 6 ++ crates/ui-egui/src/menus.rs | 8 ++ crates/ui-egui/src/panels/layers.rs | 23 ++++++ crates/ui-egui/src/panels/styles.rs | 11 +++ docs/parity.md | 20 ++--- 10 files changed, 369 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5b6a69ce1..96cac4bd6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`). +- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd composite), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index f9ce8e2e7..660708d1b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -353,6 +353,88 @@ pub fn specs() -> Vec { ok() }) }), + cmd!( + "layer.merge", + "Merge Layers", + [], + None, + "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", + has_doc, + |s, p| { + let ids: Vec = + p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); + let into = p + .get("into") + .and_then(Value::as_u64) + .map(LayerId) + .or_else(|| ids.first().copied()) + .ok_or_else(|| bad("layer.merge", "no layers"))?; + s.edit(|d, _| { + if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { + return Err(bad("layer.merge", "no such layer")); + } + let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if !sp.items.iter().any(|i| gone.contains(&i.layer)) { + continue; + } + for it in &mut std::sync::Arc::make_mut(sp).items { + if gone.contains(&it.layer) { + std::sync::Arc::make_mut(it).layer = into; + } + } + } + d.layers.retain(|l| !gone.contains(&l.id)); + Ok(json!({"merged": gone.len()})) + }) + } + ), + cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { + s.edit(|d, _| { + let used: std::collections::HashSet = + d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); + let before = d.layers.len(); + if used.is_empty() { + // Nothing anywhere: keep the first layer. + d.layers.truncate(1); + } else { + d.layers.retain(|l| used.contains(&l.id)); + } + Ok(json!({"deleted": before - d.layers.len()})) + }) + }), + cmd!( + "layer.others", + "Hide/Lock Others", + [], + None, + "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", + has_doc, + |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); + s.edit(|d, _| { + for l in &mut d.layers { + if show { + l.visible = true; + } + if unlock { + l.locked = false; + } + if l.id != id { + if hide { + l.visible = false; + } + if lock { + l.locked = true; + } + } + } + ok() + }) + } + ), cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; @@ -413,3 +495,32 @@ mod setup_tests { s.doc().unwrap().doc.check().unwrap(); } } + +#[cfg(test)] +mod layer_tests { + use serde_json::json; + + #[test] + fn merge_delete_unused_hide_and_lock_others() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": l2})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); + s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); + let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); + let _ = (l1, l3); + let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.len(), 1); + assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); + } +} diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c5ad4fb71..117a002a3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -47,6 +47,24 @@ pub fn specs() -> Vec { ok() }) }), + cmd!( + "transform.again", + "Transform Again", + ["Object", "Transform Again"], + Some("Cmd+Alt+3"), + "{individually?: bool, sequence?: bool, ids?} — repeat the last transform (or the whole sequence applied to this selection), on the selection as a whole or on each object about its own centre", + has_selection, + transform_again + ), + cmd!( + "transform.clear", + "Clear Transformations", + ["Object", "Transform"], + None, + "{ids?} — remove rotation, shear and scaling (the object keeps its centre)", + has_selection, + clear_transformations + ), cmd!("transform.shear", "Shear", ["Object", "Transform"], None, "{angle (degrees), ids?}", has_selection, |s, p| { let a = f64_or(p, "angle", 0.0).clamp(-85.0, 85.0).to_radians().tan(); apply_about_center(s, p, Affine::new([1.0, 0.0, a, 1.0, 0.0, 0.0])) @@ -670,6 +688,48 @@ pub(crate) fn scale_item(it: &mut Item, m: Affine, k: f64, strokes: bool) { } } +fn transform_again(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let (list, _, _) = &s.transforms; + if list.is_empty() { + return Err(bad("transform.again", "no transform to repeat")); + } + let steps: Vec<(String, Value)> = if bool_or(p, "sequence", false) { list.clone() } else { vec![list.last().cloned().expect("non-empty")] }; + let mut groups: Vec> = if bool_or(p, "individually", false) { ids.iter().map(|i| vec![*i]).collect() } else { vec![ids.clone()] }; + // Run the steps directly (no recording, one undo step for the whole repeat). + let saved = s.transforms.clone(); + for (cmd, params) in &steps { + let spec = super::find_command(cmd).ok_or_else(|| bad("transform.again", "unknown transform"))?; + for g in &groups { + let mut q = params.clone(); + q["ids"] = json!(g.iter().map(|i| i.0).collect::>()); + (spec.run)(s, &q)?; + } + // Moving a copy selects the copies: later steps (and the next repeat) act on them. + if bool_or(params, "copy", false) { + groups = vec![s.doc()?.selection.items.clone()]; + } + } + s.transforms = saved; + Ok(json!({"repeated": steps.len()})) +} + +fn clear_transformations(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|d, _| { + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if matches!(it.content, Content::Group { .. }) && it.shape == Shape::Group { + continue; + } + let c = it.bounds().center(); + let inner = it.inner_bounds().center(); + it.xf = Affine::translate(c - inner); + } + ok() + }) +} + fn apply_about_center(s: &mut Session, p: &Value, a: Affine) -> Result { let ids = targets(s, p)?; s.edit(|d, _| { @@ -1311,3 +1371,40 @@ mod gradient_tests { assert_eq!(fill(&s).gradient_angle, Some(90.0)); } } + +#[cfg(test)] +mod again_tests { + use super::*; + + #[test] + fn transform_again_sequence_and_clear() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap()["id"].as_u64().unwrap()); + let b = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds(); + assert!(s.execute("transform.again", &json!({})).is_err()); + s.execute("transform.move", &json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("transform.again", &json!({})).unwrap(); + assert_eq!(b(&s).x0, 120.0); + s.execute("transform.rotate", &json!({"angle": 90})).unwrap(); + // Sequence: move 10 then rotate 90 again. + s.execute("transform.again", &json!({"sequence": true})).unwrap(); + // The 100×50 frame is now 180° round from where it started: still 100 wide. + assert!((b(&s).width() - 100.0).abs() < 1e-6, "{:?}", b(&s)); + // Clear Transformations: 180° in total → upright again, same centre. + let c = b(&s).center(); + s.execute("transform.clear", &json!({})).unwrap(); + let it = s.doc().unwrap().doc.item(id).unwrap().clone(); + assert_eq!(it.xf.as_coeffs()[..4], [1.0, 0.0, 0.0, 1.0]); + assert!((it.bounds().center() - c).hypot() < 1e-6); + assert!((it.bounds().width() - 100.0).abs() < 1e-6); + // Step and repeat via Transform Again on a moved copy. + let n = s.doc().unwrap().doc.spreads[0].items.len(); + s.execute("transform.move", &json!({"dx": 0, "dy": 60, "copy": true})).unwrap(); + s.execute("transform.again", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads[0].items.len(), n + 2); + let ys: Vec = d.spreads[0].items[n..].iter().map(|i| i.bounds().y0.round()).collect(); + assert_eq!(ys[1] - ys[0], 60.0, "{ys:?}"); + } +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 65953593e..266e0b2d7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -149,6 +149,15 @@ pub fn specs() -> Vec { has_text_or_frames, open_type ), + cmd!( + "style.breakLink", + "Break Link to Style", + [], + None, + "{kind: paragraph|character} — the selected text keeps its look as local formatting, with [No Paragraph Style] / [None]", + has_text_or_frames, + break_link + ), cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), ] } @@ -520,6 +529,45 @@ fn step_size(s: &mut Session, delta: f64) -> Result { format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)})) } +fn break_link(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::{CharFormat, CharProps, ParaProps}; + let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))), + }; + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.clone(); + if para { + st.format_paras(r, |pf| { + let (pp, pc) = styles.resolve_para(pf); + pf.style = designcraft_doc::styles::NO_PARA_STYLE.into(); + pf.para = ParaProps::default().delta(&pp); + pf.chars = CharProps::default().delta(&pc); + }); + } else { + let ranges = st.para_ranges(); + for (pi, pr) in ranges.into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + if a >= b { + continue; + } + let (_, pc) = styles.resolve_para(&st.paras[pi]); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + *f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) }; + }); + } + } + } + ok() + }) +} + fn open_type(s: &mut Session, p: &Value) -> Result { use designcraft_doc::otf; let cur = selection_attrs(s, &json!({}))?; @@ -706,6 +754,30 @@ mod open_type_tests { assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err()); } + #[test] + fn break_link_to_paragraph_and_character_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Em"})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "character"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after["paragraphStyle"], "[No Paragraph Style]"); + assert_eq!(after["characterStyle"], "[None]"); + assert_eq!(after["chars"], before["chars"], "same look"); + assert_eq!(after["para"], before["para"]); + } + /// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4). #[test] fn oldstyle_figures_change_the_glyphs() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2d628900..faee76b7d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -163,6 +163,10 @@ pub struct Session { /// Graphic loaded in the place cursor: (asset, natural size in points). pub loaded: Option<(designcraft_doc::AssetId, (f64, f64))>, pub(crate) untitled: u32, + /// Transforms applied to the current selection, oldest first (Transform Again / Sequence + /// Again), with the selection they were applied to and whether the last one came from a + /// tool interaction (its previews replace each other). + pub(crate) transforms: (Vec<(String, Value)>, Vec, bool), } impl Default for Session { @@ -185,6 +189,7 @@ impl Session { text_clipboard: None, ui_requests: vec![], loaded: None, + transforms: Default::default(), untitled: 0, } } @@ -234,6 +239,7 @@ impl Session { (spec.enabled)(self).map_err(|e| EngineError::Disabled(id.into(), e))?; let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; + self.record_transform(id, params); // Record undo if the document changed (and we're not previewing an interaction). if let (Some((uid, old)), Some(st)) = (before, self.active_mut()) && st.uid == uid @@ -250,6 +256,30 @@ impl Session { Ok(r) } + /// Remember a move/rotate/scale/shear/flip for Transform Again. + fn record_transform(&mut self, id: &str, params: &Value) { + if !matches!(id, "transform.move" | "transform.rotate" | "transform.scale" | "transform.shear" | "transform.flip") { + return; + } + let Some(st) = self.active() else { return }; + let sel = st.selection.items.clone(); + let live = st.interaction.is_some(); + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("ids"); + o.remove("id"); + } + let (list, on, was_live) = &mut self.transforms; + if *on != sel { + list.clear(); + *on = sel; + } else if *was_live && live && list.last().is_some_and(|l| l.0 == id) { + list.pop(); + } + list.push((id.to_string(), p)); + *was_live = live; + } + pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 644593a88..8b18d9185 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -152,6 +152,8 @@ impl Session { if let Some((c, p)) = it.preview { self.journal.push((c, p)); } + // The interaction's last preview is the transform to repeat; the next one starts afresh. + self.transforms.2 = false; Ok(()) } @@ -162,6 +164,10 @@ impl Session { st.doc = it.doc; st.selection = it.selection; st.revision += 1; + if self.transforms.2 { + self.transforms.0.pop(); + self.transforms.2 = false; + } } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 122882375..44df4dfb7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -272,6 +272,14 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:transform.shear", "-", "cmd:transform.flip", + "-", + "cmd:transform.clear", + "<", + ">Transform Again", + "cmd:transform.again", + "cmd:transform.again|Transform Again Individually|{\"individually\": true}", + "cmd:transform.again|Transform Sequence Again|{\"sequence\": true}", + "cmd:transform.again|Transform Sequence Again Individually|{\"sequence\": true, \"individually\": true}", "<", ">Arrange", "cmd:object.bringToFront", diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 1ec29e330..efac14b20 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -149,6 +149,29 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("object.setLayer", json!({"layer": l.id.0})); ui.close(); } + ui.separator(); + let active = app.session.active().map(|d| d.active_layer); + if let Some(a) = active.filter(|a| *a != l.id) + && ui.button("Merge into Active Layer").clicked() + { + let _ = app.run("layer.merge", json!({"ids": [a.0, l.id.0], "into": a.0})); + ui.close(); + } + for (label, params) in [ + ("Hide Others", json!({"id": l.id.0, "hide": true})), + ("Lock Others", json!({"id": l.id.0, "lock": true})), + ("Show All Layers", json!({"id": l.id.0, "show": true})), + ("Unlock All Layers", json!({"id": l.id.0, "unlock": true})), + ] { + if ui.button(label).clicked() { + let _ = app.run("layer.others", params); + ui.close(); + } + } + if ui.button("Delete Unused Layers").clicked() { + let _ = app.run("layer.deleteUnused", json!({})); + ui.close(); + } }); if !open { continue; diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 216a0630a..d6f747375 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -43,6 +43,17 @@ fn list(app: &mut DesignApp, ui: &mut egui::Ui, para: bool) { 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(); + } + }); } ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/docs/parity.md b/docs/parity.md index fe7b30202..16bea7936 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,27 +5,27 @@ 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: 70%** over 275 features. +**Weighted breadth parity: 72%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 33 | 23 | 41 | 46% | +| P1 | 97 | 38 | 23 | 36 | 51% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | |---|---| | Application shell & workspace | 69% | | Documents, pages, spreads | 71% | -| Layers | 74% | +| Layers | 95% | | Frames, shapes & paths | 76% | -| Transform | 78% | +| Transform | 93% | | Fill, stroke, colour | 69% | | Effects & transparency | 50% | | Placing & links | 66% | | Type & text frames | 77% | | Typography | 79% | -| Styles | 61% | +| Styles | 71% | | Tables | 73% | | Long documents | 58% | | Interactivity & digital | 30% | @@ -89,8 +89,8 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Layers | Layers panel | P0 | D | | Layers | Layer options | P0 | D | | Layers | Move items between layers | P0 | D | -| Layers | Merge layers, delete unused | P1 | M | -| Layers | Hide/Lock others | P1 | M | +| Layers | Merge layers, delete unused | P1 | D | +| Layers | Hide/Lock others | P1 | D | | Layers | Page item names in Layers panel | P1 | D | | Layers | Layer colour drives selection colour | P0 | D | | Layers | Object Layer Options for placed files | P2 | M | @@ -132,8 +132,8 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Transform | Transform panel / Control | P0 | D | | Transform | Free Transform tool | P0 | D | | Transform | Rotate, Scale, Shear tools + dialogs | P0 | D | -| Transform | Transform Again family | P1 | M | -| Transform | Clear Transformations | P1 | M | +| Transform | Transform Again family | P1 | D | +| Transform | Clear Transformations | P1 | D | | Transform | Transformations are Totals pref | P1 | M | | Transform | Scale strokes / content options | P0 | D | | Transform | Align & Distribute | P0 | D | @@ -242,7 +242,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Styles | Table & cell styles | P1 | M | | Styles | Style groups | P1 | M | | Styles | Quick Apply | P1 | D | -| Styles | Break link to style | P1 | M | +| Styles | Break link to style | P1 | D | | Styles | Style mapping on import | P1 | P | | Styles | Style export tagging | P2 | P | | Tables | Create / Insert Table | P1 | D |