Merge pull request #1294 from KnottyDyes/squad/place-never-into-line-v2

Place: implicit image placement preserves lines and open paths
This commit is contained in:
aiongg authored and GitHub committed 2026-11-03 21:17:15 -05:00
commit b18ed2e7ad
6 files changed
+141 -6

No files matched your search

+1
View File
@@ -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.
+58 -1
View File
@@ -105,7 +105,7 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
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<dyn std::error::Error>> {
let image = image_file()?;
let mut session = Session::new();
session.execute("file.new", &json!({}))?;
let selected = session.execute(command, &params)?;
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, &params).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();
+23 -4
View File
@@ -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();
+36
View File
@@ -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<SubPath>, 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::<Vec<_>>();
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();
+1 -1
View File
@@ -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}))];
}
+22
View File
@@ -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<dyn Tool>, 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());