O: Transform Again family, Clear Transformations, layer merge/unused/others, Break Link to Style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 15:09:32 -04:00
co-authored by Claude Opus 5.5
parent 4b2417a8c0
commit 571e1043f7
10 changed files with 369 additions and 10 deletions
+1
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`).
- 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).
+111
View File
@@ -353,6 +353,88 @@ pub fn specs() -> Vec<CommandSpec> {
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<LayerId> =
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<LayerId> = 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<LayerId> =
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);
}
}
+97
View File
@@ -47,6 +47,24 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Vec<ItemId>> = 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::<Vec<_>>());
(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<Value> {
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<Value> {
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<f64> = d.spreads[0].items[n..].iter().map(|i| i.bounds().y0.round()).collect();
assert_eq!(ys[1] - ys[0], 60.0, "{ys:?}");
}
}
+72
View File
@@ -149,6 +149,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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() {
+30
View File
@@ -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<designcraft_doc::ItemId>, 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<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
+6
View File
@@ -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;
}
}
}
}
+8
View File
@@ -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",
+23
View File
@@ -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;
+11
View File
@@ -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| {
+10 -10
View File
@@ -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.
<!-- SUMMARY -->
**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 |