From f55b94f3ff32c88bcd99b1eed7ef20cd86b458f8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 10:21:39 +0200 Subject: [PATCH] fix(type): Create Outlines makes one compound path per line As in InDesign, outlining a text frame gives a group with one compound path per line (split further by text colour within a line), so Ungroup leaves each line on its own: LYH Studio episode 14 ungroups the outlined title at 4:56 to tighten the space between its lines before making one compound path again. A single line in one colour stays a single compound path. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/paths.rs | 38 ++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..8838a2cee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Create Outlines makes one compound path per line (and per text colour within a line), grouped, as InDesign does: Ungroup then leaves each line on its own to be spaced and made into one compound path again. A single line in one colour stays a single compound path. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..e9511f260 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{ids?} — text frames become compound paths of their glyphs (one per text colour, grouped when several) → {ids}", + "{ids?} — text frames become compound paths of their glyphs (one per line and text colour, grouped when several, so Ungroup separates the lines) → {ids}", has_selection, create_outlines ), @@ -245,9 +245,10 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { }; let cs = designcraft_compose::compose_story(d, tf.story, &Default::default()); let Some(ft) = cs.frame(*id) else { continue }; - // One path per text look (fill, stroke). - let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new(); - for l in &ft.lines { + // One compound path per line and text look (fill, stroke), grouped: as in InDesign, + // Ungroup then leaves each line on its own. + let mut runs: Vec<((usize, String, f32, String, f32, f64), BezPath)> = Vec::new(); + for (li, l) in ft.lines.iter().enumerate() { for g in l.glyphs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; let outline = db.outline(&g.face, g.gid); @@ -256,7 +257,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { } let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); + let key = (li, st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); let k = match runs.iter().position(|r| r.0 == key) { Some(k) => k, None => { @@ -274,7 +275,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result { let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); let text_xf = d.text_xf(&it); - let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { + let make = |d: &mut Document, ((_, fill, ft, stroke, stt, sw), bp): ((usize, String, f32, String, f32, f64), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; @@ -454,6 +455,31 @@ mod tests { assert!(d.stories.is_empty() || !d.stories.values().any(|st| st.frames.contains(&ItemId(id)))); } + #[test] + fn outlines_of_several_lines_ungroup_line_by_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "GOOD\nMORNING"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let group = d.item(ItemId(g)).unwrap(); + assert!(group.is_group(), "a group of the lines"); + let lines: Vec<_> = group.children().iter().map(|c| c.bounds()).collect(); + assert_eq!(lines.len(), 2, "one compound path per line"); + assert!(lines[0].y1 <= lines[1].y0 + 1.0, "GOOD above MORNING: {lines:?}"); + // Ungroup: each line on its own, to space them and make one compound path again. + s.execute("object.ungroup", &json!({})).unwrap(); + let sel = s.doc().unwrap().selection.items.clone(); + assert_eq!(sel.len(), 2); + let d = s.doc().unwrap().doc.clone(); + assert!(sel.iter().all(|i| d.item(*i).is_some_and(|it| it.shape == Shape::Path && it.path.subpaths.len() >= 4))); + s.execute("object.makeCompoundPath", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.items.len(), 1); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn scissors_split_open_and_closed_paths() { let mut s = Session::new();