diff --git a/ROADMAP.md b/ROADMAP.md index 661d1ca9f..9147a1741 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -432,6 +432,7 @@ Newest first. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. - **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change. +- **2026-10-18** · Placing an image or media without coordinates, or clicking the loaded place cursor, no longer turns a line into a frame: lines and paths whose points all lie on one line are left as they are and the content gets a new frame. Frames, and open paths that enclose an area, take the content as before. No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index a3ae11df1..c6a581d5d 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -105,7 +105,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { return None; } let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.takes_graphic())) + st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.path.encloses_area() && it.takes_graphic())) }); // `"spread": null` counts as omitted, like the other placement parameters. let spread = match p.get("spread") { @@ -490,6 +490,63 @@ pub(super) mod image_place_tests { } } + fn assert_implicit_placement_preserves_geometry(command: &str, params: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute(command, ¶ms)?; + session.execute("object.stroke", &json!({"weight": 6}))?; + let id = ItemId(selected["id"].as_u64().ok_or("missing selected item id")?); + let original = serde_json::to_value(session.doc()?.doc.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?)?; + + let placed = session.execute("file.place", &json!({"path": image.path()}))?; + + assert_ne!(placed["id"], selected["id"], "{command}"); + let document = &session.doc()?.doc; + assert_eq!(serde_json::to_value(document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?)?, original, "{command}"); + let placed_id = ItemId(placed["id"].as_u64().ok_or("missing placed item id")?); + let item = document.item(placed_id).ok_or(designcraft_doc::DocError::NoItem(placed_id))?; + assert_eq!(item.bounds(), Rect::new(36.0, 36.0, 76.0, 56.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert_eq!(document.spreads[0].items.len(), 2); + Ok(()) + } + + #[test] + fn implicit_placement_preserves_selected_line() { + assert_implicit_placement_preserves_geometry("line.create", json!({"a": [18, 600], "b": [594, 600]})).unwrap(); + } + + #[test] + fn implicit_placement_preserves_selected_straight_open_path() { + assert_implicit_placement_preserves_geometry( + "path.create", + json!({"anchors": [{"p": [20, 30]}, {"p": [80, 90]}, {"p": [140, 150]}], "closed": false}), + ) + .unwrap(); + } + + /// Paths with an area take the image: a closed empty frame, and an open V as before. + #[test] + fn implicit_placement_fills_selected_frames_and_open_paths_with_an_area() { + let image = image_file().unwrap(); + for (command, params) in [ + ("frame.create", json!({"rect": [100, 120, 200, 220]})), + ("path.create", json!({"anchors": [{"p": [20, 30]}, {"p": [80, 90]}, {"p": [140, 30]}], "closed": false})), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute(command, ¶ms).unwrap(); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"], "{command}"); + let document = &session.doc().unwrap().doc; + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Graphic(_)), "{command}"); + assert_eq!(document.spreads[0].items.len(), 1, "{command}"); + } + } + #[test] fn explicit_frame_wins_over_placement_and_selection() { let image = image_file().unwrap(); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index a4e3f5ae6..eab1027f6 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -16,10 +16,13 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, p.get("width").and_then(Value::as_f64).unwrap_or(if video { 320.0 } else { 200.0 }), p.get("height").and_then(Value::as_f64).unwrap_or(if video { 180.0 } else { 48.0 }), ); - let target = super::id_param(p, "frame").or_else(|| { - let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) - }); + let target = + super::id_param(p, "frame").or_else(|| { + let st = s.active()?; + st.selection.items.iter().copied().find(|i| { + st.doc.item(*i).is_some_and(|it| it.path.encloses_area() && matches!(it.content, Content::Unassigned | Content::Graphic(_))) + }) + }); let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); @@ -157,6 +160,22 @@ mod tests { use crate::Session; + #[test] + fn media_placement_leaves_a_selected_line_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let line = s.execute("line.create", &json!({"a": [18, 600], "b": [594, 600]})).unwrap(); + let original = serde_json::to_value(s.doc().unwrap().doc.item(designcraft_doc::ItemId(line["id"].as_u64().unwrap())).unwrap()).unwrap(); + let fake = super::super::file::base64_encode(b"\x00\x00\x00\x18ftypmp42"); + + let r = s.execute("file.place", &json!({"base64": fake, "name": "clip.mp4"})).unwrap(); + + assert_ne!(r["id"], line["id"]); + let d = &s.doc().unwrap().doc; + assert_eq!(serde_json::to_value(d.item(designcraft_doc::ItemId(line["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + assert_eq!(d.spreads[0].items.len(), 2); + } + #[test] fn media_frames_place_render_and_export() { let mut s = Session::new(); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 946b347b6..175e210b7 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -411,6 +411,23 @@ impl PathData { pub fn length(&self) -> f64 { self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum() } + /// True if some subpath encloses an area once closed: a frame, or an open path such as a V. + /// A line, or a path whose points all lie on one line, encloses none. + pub fn encloses_area(&self) -> bool { + self.subpaths.iter().any(|sp| { + let mut anchors = sp.anchors.clone(); + // An open subpath closes with a straight segment: its end handles are unused. + if !sp.closed { + if let Some(a) = anchors.first_mut() { + a.h_in = a.p; + } + if let Some(a) = anchors.last_mut() { + a.h_out = a.p; + } + } + SubPath::new(anchors, true).area().abs() > 1e-6 + }) + } } fn push_curve(cur: &mut Option, last: Point, c1: Point, c2: Point, p: Point) { @@ -440,6 +457,25 @@ mod tests { assert_eq!(two.bounds(), Some(Rect::new(5.0, 7.0, 50.0, 50.0))); } + #[test] + fn lines_enclose_no_area_and_open_vs_do() { + let pts = |p: &[(f64, f64)]| p.iter().map(|&(x, y)| Point::new(x, y)).collect::>(); + let open = |p: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts(p), false)); + assert!(!open(&[(0.0, 0.0), (100.0, 0.0)]).encloses_area()); + assert!(!open(&[(0.0, 0.0), (50.0, 50.0), (100.0, 100.0)]).encloses_area()); + assert!(open(&[(20.0, 30.0), (80.0, 90.0), (140.0, 30.0)]).encloses_area()); + let square = SubPath::polyline(&pts(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), true); + let line = SubPath::polyline(&pts(&[(20.0, 0.0), (40.0, 0.0)]), false); + assert!(PathData::new(vec![line.clone(), square]).encloses_area()); + assert!(!PathData::new(vec![line]).encloses_area()); + assert!(!PathData::default().encloses_area()); + // Handles left on the open ends don't bend the closing segment. + let mut stray = SubPath::polyline(&pts(&[(0.0, 0.0), (100.0, 0.0)]), false); + stray.anchors[0].h_in = Point::new(0.0, 40.0); + stray.anchors[1].h_out = Point::new(100.0, 40.0); + assert!(!PathData::single(stray).encloses_area()); + } + #[test] fn from_bezpath_detects_smooth_on_every_open_subpath() { let mut bp = BezPath::new(); diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 1748c9cfb..c1e434a8e 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -136,7 +136,7 @@ impl Tool for PlaceGun { let click = (ev.pos - a).hypot() < cx.tol(3.0); if click && let Some((_, id)) = cx.hit(a) - && cx.doc.item(id).is_some_and(designcraft_doc::Item::takes_graphic) + && cx.doc.item(id).is_some_and(|i| i.path.encloses_area() && i.takes_graphic()) { return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))]; } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 3970f1910..4b7c893fb 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -478,6 +478,28 @@ fn turned_spread_maps_points_both_ways() { assert!(dv.x.abs() < 1e-9 && (dv.y + 10.0).abs() < 1e-9 || (dv.y - 10.0).abs() < 1e-9, "{dv:?}"); } +#[test] +fn place_gun_click_places_into_frames_but_not_onto_lines() { + let mut d = Document::new(&NewDocument::default()); + let frame = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); + let lid = d.default_layer(); + let line = ItemId(d.alloc()); + let mut it = Item::new(line, lid, Shape::GraphicLine, shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let click = |t: &mut Box, x: f64, y: f64| { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x + off.x, y + off.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + off.x, y + off.y)) + }; + let mut t = create("placeGun"); + assert_eq!(click(&mut t, 150.0, 150.0), vec![Action::Exec("place.drop".into(), serde_json::json!({"frame": frame.0}))]); + let a = click(&mut t, 200.0, 300.0); + assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "place.drop" && p.get("frame").is_none()), "{a:?}"); +} + fn add_rect(doc: &mut Document, rect: Rect) -> ItemId { let layer = doc.default_layer(); let id = ItemId(doc.alloc());