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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 10:21:39 +02:00
co-authored by Claude Opus 5.5
parent e862e0a613
commit f55b94f3ff
2 changed files with 33 additions and 6 deletions
+1
View File
@@ -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.
+32 -6
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
};
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<Value> {
}
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<Value> {
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();