mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
Merge upstream main 835edae6
Brings #514 (MCP request replay), #523 final (already in next), #403
(character fill must name a swatch), #520 and #521 finals (already in
next as their PR heads).
Conflict: crates/engine/src/cmd/object.rs: next's side throughout (transform.move doc with content?, object.fill with tint alone from #525, move_content, reorder/content_move/group/tint_alone test modules); upstream's copy_ids_tests already in next
Conflict: crates/engine/src/cmd/text.rs: both; #403's validate_char_fill after next's ruby_cmd
Conflict: crates/pdf/src/export.rs: next's side (item/item_guarded cycle guard, frame_objects; upstream's anchored loop dropped as in d11ccad3)
Conflict: crates/render/src/lib.rs: next's side (drawing_items, draw_item/draw_item_inner)
This commit is contained in:
@@ -132,6 +132,8 @@ Newest first.
|
||||
- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change.
|
||||
- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
|
||||
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
|
||||
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
|
||||
- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change.
|
||||
- **2026-10-11** · Properties › Appearance's Fill and Stroke menus gain InDesign's Tint field and, for a text frame, Apply To: Frame / Text. No readiness percentage change.
|
||||
|
||||
@@ -158,6 +158,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
over.set_json(k, v).map_err(|e| bad("find.change", e))?;
|
||||
}
|
||||
}
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?;
|
||||
let targets = scope(s, p)?;
|
||||
s.edit(|d, sel| {
|
||||
let mut count = 0usize;
|
||||
|
||||
@@ -467,6 +467,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = full;
|
||||
}
|
||||
}
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
let next = str_param(p, "nextStyle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
@@ -534,6 +535,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs {
|
||||
fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?;
|
||||
let rename = str_param(p, "rename").map(str::to_string);
|
||||
let based = p.get("basedOn").cloned();
|
||||
s.edit(|d, _| {
|
||||
@@ -739,6 +741,7 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result<Value> {
|
||||
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?;
|
||||
// Based on [None] is based on nothing.
|
||||
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
@@ -759,6 +762,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("style.character.edit", "[None] can't be edited"));
|
||||
}
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?;
|
||||
// `null` unsets an attribute (the style no longer sets it).
|
||||
let unset: Vec<String> = p
|
||||
.get("chars")
|
||||
@@ -1247,6 +1251,154 @@ fn swatch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
fn fill_session() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap();
|
||||
session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap();
|
||||
session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("style.character.create", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("type.char", &json!({"size": 18})).unwrap();
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) {
|
||||
let before = session.doc().unwrap().clone();
|
||||
let journal = session.journal.clone();
|
||||
let fill = params
|
||||
.pointer("/chars/fill")
|
||||
.or_else(|| params.pointer("/attrs/fill"))
|
||||
.or_else(|| params.get("fill"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Missing Ink");
|
||||
|
||||
let result = session.execute(command, ¶ms);
|
||||
|
||||
match result {
|
||||
Err(crate::EngineError::BadParams { cmd, msg }) => {
|
||||
assert_eq!(cmd, command);
|
||||
assert!(msg.contains(fill), "{msg}");
|
||||
assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}");
|
||||
}
|
||||
other => panic!("expected BadParams for {command}, got {other:?}"),
|
||||
}
|
||||
let after = session.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
|
||||
assert_eq!(before.selection, after.selection);
|
||||
assert_eq!(before.revision, after.revision);
|
||||
assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history));
|
||||
assert_eq!(journal, session.journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() {
|
||||
for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] {
|
||||
for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] {
|
||||
let mut session = fill_session();
|
||||
assert_fill_rejected(&mut session, "type.char", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() {
|
||||
for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
|
||||
for fill in ["Missing Ink", "#ff0000"] {
|
||||
let mut session = fill_session();
|
||||
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
|
||||
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
|
||||
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() {
|
||||
let mut session = fill_session();
|
||||
session
|
||||
.edit(|document, selection| {
|
||||
let selected = selection.text.unwrap();
|
||||
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_accepts_valid_override_when_selection_has_unknown_name() {
|
||||
let mut session = fill_session();
|
||||
session
|
||||
.edit(|document, selection| {
|
||||
let selected = selection.text.unwrap();
|
||||
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap();
|
||||
|
||||
assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() {
|
||||
for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] {
|
||||
for fill in ["Missing Ink", "#ff0000"] {
|
||||
let mut session = fill_session();
|
||||
assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() {
|
||||
for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] {
|
||||
let mut session = fill_session();
|
||||
if !fill.starts_with('[') {
|
||||
let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap();
|
||||
assert_eq!(created["name"], fill);
|
||||
}
|
||||
for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] {
|
||||
session.execute("type.char", ¶ms).unwrap();
|
||||
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
|
||||
}
|
||||
for kind in ["paragraph", "character"] {
|
||||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap();
|
||||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap();
|
||||
let document = &session.doc().unwrap().doc;
|
||||
for name in ["Colored", "Existing"] {
|
||||
let attrs = if kind == "paragraph" {
|
||||
&document.styles.para(name).unwrap().chars
|
||||
} else {
|
||||
&document.styles.char_style(name).unwrap().chars
|
||||
};
|
||||
assert_eq!(attrs.fill.as_deref(), Some(fill));
|
||||
}
|
||||
}
|
||||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1);
|
||||
let selected = session.doc().unwrap().selection.text.unwrap();
|
||||
let story = session.doc().unwrap().doc.story(selected.story).unwrap();
|
||||
assert_eq!(story.text, "Bye");
|
||||
assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() {
|
||||
for chars in [json!({"size": 18}), json!({"fill": null})] {
|
||||
let mut session = fill_session();
|
||||
session.execute("type.char", &json!({"attrs": chars})).unwrap();
|
||||
for kind in ["paragraph", "character"] {
|
||||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap();
|
||||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap();
|
||||
}
|
||||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_components_at_most_one_stay_percent() {
|
||||
use designcraft_color::Color;
|
||||
|
||||
@@ -1297,6 +1297,18 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_chars(s, &Value::Object(body))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
|
||||
if let Some(fill) = &attrs.fill
|
||||
&& document.swatch(fill).is_none()
|
||||
{
|
||||
return Err(bad(
|
||||
command_id,
|
||||
format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
let mut a = CharAttrs::default();
|
||||
if let Some(o) = attrs.as_object() {
|
||||
@@ -1304,6 +1316,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
|
||||
}
|
||||
}
|
||||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||||
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
|
||||
let targets = format_targets(s);
|
||||
// A caret in text: the format is for the next typing.
|
||||
|
||||
@@ -78,15 +78,11 @@ impl Backend for Remote {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
self.conn = None;
|
||||
self.roundtrip(&line).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!("DesignCraft app at {} is not reachable: {e}", self.addr)
|
||||
})?
|
||||
return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr));
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
@@ -105,3 +101,55 @@ impl Backend for Remote {
|
||||
format!("connected to the DesignCraft app at {}", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn remote_does_not_replay_when_reply_is_lost() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let server = std::thread::spawn(move || {
|
||||
let first = {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(request["method"], "engine.execute");
|
||||
assert_eq!(request["params"]["command"], "frame.create");
|
||||
request
|
||||
};
|
||||
let mut applied = 1;
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
if request == first {
|
||||
applied += 1;
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
||||
} else {
|
||||
assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}}));
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap();
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
|
||||
let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}));
|
||||
let next = remote.call("document.inspect", json!({}));
|
||||
let applied = server.join().unwrap();
|
||||
|
||||
assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}");
|
||||
assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}");
|
||||
assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ claude mcp add designcraft -- designcraft-cli mcp
|
||||
| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work |
|
||||
|
||||
In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so
|
||||
what the agent does shows up live in the window. The connection is re-established once if the app restarts.
|
||||
what the agent does shows up live in the window. A failed request is not resent: the operation may have completed,
|
||||
so inspect the document before retrying. The next request re-establishes the connection.
|
||||
|
||||
## Coordinates
|
||||
|
||||
|
||||
Reference in New Issue
Block a user