mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-05 10:10:35 +00:00
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:
6 files changed
+141
-6
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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, ¶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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}))];
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user