diff --git a/ROADMAP.md b/ROADMAP.md index 150e878..d1aa667 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. On-path caret drawing and IDML TextPath are still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6608977..1dda64b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -303,7 +303,37 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; let grid = (inc > 0.0).then_some((start - ty, inc)); - out.push(FrameSpec { id: fid, area: item.text_area(), opts: tf.options.clone(), exclusions, page_name, page, grid, left_page }); + // Type on a path: one line as long as the path (from the start offset). + let (area, opts) = match &tf.options.path { + Some(pt) => { + let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length(); + let size = st + .paras + .first() + .map(|p| doc.styles.resolve_para(p).1) + .map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max)) + .unwrap_or(12.0); + let opts = TextFrameOptions { + columns: 1, + inset: [0.0; 4], + first_baseline: designcraft_doc::FirstBaseline::Ascent, + path: None, + ..tf.options.clone() + }; + (Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts) + } + None => (item.text_area(), tf.options.clone()), + }; + out.push(FrameSpec { + id: fid, + area, + opts, + exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, + page_name, + page, + grid, + left_page, + }); } out } @@ -1593,3 +1623,53 @@ fn line_dist(l: &Line, y: f64) -> f64 { #[cfg(test)] mod tests; + +/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space +/// (grouped by run style). +pub fn path_glyphs( + cs: &ComposedStory, + ft: &FrameText, + path: &designcraft_geom::BezPath, + pt: &designcraft_doc::PathType, +) -> Vec<(u32, designcraft_geom::BezPath)> { + let db = designcraft_fonts::FontDb::global(); + let warp = designcraft_geom::warp::PathWarp::new(path, pt.flip); + let mut runs: Vec<(u32, designcraft_geom::BezPath)> = Vec::new(); + for l in &ft.lines { + // The part of the type that sits on the path. + let on = match pt.align { + designcraft_doc::PathAlign::Baseline => l.baseline, + designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent, + designcraft_doc::PathAlign::Descender => l.baseline + l.descent, + designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0, + }; + for g in l.glyphs.iter().filter(|g| g.visible) { + let outline = db.outline(&g.face, g.gid); + if outline.elements().is_empty() { + continue; + } + let style = &cs.styles[g.style as usize]; + let mid = g.x + g.adv / 2.0; + let Some((p, a)) = warp.at(pt.start + mid) else { continue }; + let place = designcraft_geom::Affine::translate(p.to_vec2()) + * designcraft_geom::Affine::rotate(a) + * designcraft_geom::Affine::translate((-mid, -on)); + let skew = if style.skew != 0.0 { + designcraft_geom::Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) + } else { + designcraft_geom::Affine::IDENTITY + }; + let m = + place * designcraft_geom::Affine::translate((g.x, l.baseline + g.y)) * skew * designcraft_geom::Affine::scale_non_uniform(g.sx, g.sy); + let bp = match runs.iter_mut().find(|r| r.0 == g.style) { + Some(r) => &mut r.1, + None => { + runs.push((g.style, designcraft_geom::BezPath::new())); + &mut runs.last_mut().expect("pushed").1 + } + }; + bp.extend((m * outline.as_ref().clone()).elements().iter().copied()); + } + } + runs +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 4507c4a..de177a2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -282,6 +282,31 @@ pub struct TextFrameOptions { /// Use a custom baseline grid for this frame. #[serde(skip_serializing_if = "Option::is_none")] pub baseline_grid: Option<(f64, f64)>, + /// Type on a Path: the text runs along the item's path instead of filling it. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// Type on a Path Options. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct PathType { + /// Where the text starts, as a distance along the path. + pub start: f64, + /// Run the other way (the text sits on the other side). + pub flip: bool, + pub align: PathAlign, +} + +/// Which part of the type sits on the path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAlign { + #[default] + Baseline, + Ascender, + Descender, + Center, } impl Default for TextFrameOptions { @@ -304,6 +329,7 @@ impl Default for TextFrameOptions { column_rule_weight: 1.0, column_rule_color: designcraft_color::swatch::BLACK.into(), baseline_grid: None, + path: None, } } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 19bedce..78eb8ac 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ mod notes; mod object; mod overrides; mod package; +mod path_type; mod paths; mod place_text; pub mod preflight; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(object::specs()); v.extend(anchors::specs()); v.extend(paths::specs()); + v.extend(path_type::specs()); v.extend(package::specs()); v.extend(print::specs()); v.extend(text::specs()); diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs new file mode 100644 index 0000000..883f0c2 --- /dev/null +++ b/crates/engine/src/cmd/path_type.rs @@ -0,0 +1,162 @@ +//! Type on a Path: a story set along any path (lines, curves, shapes), with Type on a Path +//! Options (start, flip, alignment) and Delete Type from Path. + +use designcraft_doc::{Content, ItemId, PathAlign, PathType, Selection, Story, StoryId, TextFrame, TextSel}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "type.onPath", + "Type on a Path", + [], + None, + "{id: path, text?, start?: distance along the path, flip?, align?: baseline|ascender|descender|center} → {story} — the text cursor goes into it", + has_doc, + on_path + ), + cmd!( + "type.pathOptions", + "Type on a Path Options…", + ["Type", "Type on a Path"], + None, + "{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}", + has_doc, + path_options + ), + ] +} + +fn align_param(p: &Value) -> Result> { + p.get("align").map(|v| serde_json::from_value(v.clone()).map_err(|e| bad("type.onPath", format!("align: {e}")))).transpose() +} + +fn on_path(s: &mut Session, p: &Value) -> Result { + let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; + let text = super::str_param(p, "text").unwrap_or("").to_string(); + let mut pt = PathType { + start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), + flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), + ..Default::default() + }; + if let Some(a) = align_param(p)? { + pt.align = a; + } + s.edit(|d, sel| { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + match &it.content { + Content::Unassigned => {} + Content::Text(_) => return Err(bad("type.onPath", "the object already holds text")), + _ => return Err(bad("type.onPath", "type goes on empty paths, lines and shapes")), + } + let sid = StoryId(d.alloc()); + let mut st = Story::with_text(sid, &text, Default::default()); + st.frames = vec![id]; + d.stories.insert(sid, std::sync::Arc::new(st)); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut tf = TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(pt); + it.content = Content::Text(tf); + *sel = Selection::default(); + let end = text.len(); + sel.text = Some(TextSel { story: sid, anchor: end, focus: end, cell: None, frame: Some(id) }); + Ok(json!({"story": sid.0})) + }) +} + +fn path_options(s: &mut Session, p: &Value) -> Result { + let id: ItemId = match super::id_param(p, "id") { + Some(i) => i, + None => { + let st = s.doc()?; + st.selection + .items + .first() + .copied() + .or_else(|| st.selection.text.and_then(|t| st.doc.story(t.story)?.frames.first().copied())) + .ok_or_else(|| bad("type.pathOptions", "select type on a path"))? + } + }; + let align = align_param(p)?; + let delete = p.get("delete").and_then(Value::as_bool).unwrap_or(false); + let p = p.clone(); + s.edit(|d, sel| { + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) }; + let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) }; + if delete { + let sid = tf.story; + it.content = Content::Unassigned; + d.stories.remove(&sid); + sel.text = None; + return Ok(json!({"deleted": true})); + } + if let Some(v) = p.get("start").and_then(Value::as_f64) { + pt.start = v.max(0.0); + } + if let Some(v) = p.get("flip").and_then(Value::as_bool) { + pt.flip = v; + } + if let Some(a) = align { + pt.align = a; + } + Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn type_on_a_path_renders_along_the_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A vertical line: the text must run down it, not across. + let id = s.execute("line.create", &json!({"a": [300, 100], "b": [300, 600]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "Along the line we go"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + assert!(cs.overset_at.is_none()); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + let designcraft_doc::Content::Text(tf) = &it.content else { panic!() }; + let glyphs = designcraft_compose::path_glyphs(&cs, &cs.frames[0], &it.path.to_bezpath(), tf.options.path.as_ref().unwrap()); + let b = glyphs.iter().map(|(_, bp)| designcraft_geom::kurbo::Shape::bounding_box(bp)).reduce(|a, b| a.union(b)).unwrap(); + assert!(b.height() > b.width() * 3.0, "runs down the line: {b:?}"); + assert!(b.x0 > 280.0 && b.x1 < 320.0); + // Typing at the cursor adds to it; options; delete. + s.execute("text.insert", &json!({"text": "!"})).unwrap(); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with('!')); + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 20, "flip": true, "align": "center"})).unwrap(); + assert_eq!(r["align"], "center"); + // Too much text oversets (one line only). + s.execute("text.insert", &json!({"text": " more words".repeat(60)})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at.is_some()); + s.execute("type.pathOptions", &json!({"id": id, "delete": true})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.story(sid).is_none()); + assert!(matches!(d.item(designcraft_doc::ItemId(id)).unwrap().content, designcraft_doc::Content::Unassigned)); + // Rendered output has ink along the line. + let mut s2 = Session::new(); + s2.execute("file.new", &json!({})).unwrap(); + let id = s2.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s2.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); + s2.execute("type.onPath", &json!({"id": id, "text": "MMMMMMMM"})).unwrap(); + let d = s2.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s2.cache, 0, 1.0, false, &Default::default()).unwrap(); + let ink = (100..200).filter(|&x| img.pixel(x, 295)[0] < 128).count(); + assert!(ink > 8, "glyphs sit on the line: {ink}"); + assert_eq!((100..200).filter(|&x| img.pixel(x, 306)[0] < 128).count(), 0, "nothing hangs below the baseline"); + let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap(); + assert!(pdf.len() > 1000); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 4fce052..2e17785 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -15,6 +15,7 @@ pub mod pathfinder; pub mod shapes; pub mod snap; pub mod units; +pub mod warp; pub use kurbo; pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs new file mode 100644 index 0000000..fe6b890 --- /dev/null +++ b/crates/geom/src/warp.rs @@ -0,0 +1,80 @@ +//! Type on a path: positions along a path by arc length, with the tangent angle. + +use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathSeg, Point}; + +/// A path measured for placing glyphs along it. +#[derive(Clone, Debug)] +pub struct PathWarp { + segs: Vec<(PathSeg, f64)>, + total: f64, + /// Glyphs run the other way (Type on a Path Options › Flip). + flip: bool, +} + +impl PathWarp { + pub fn new(bp: &BezPath, flip: bool) -> Self { + let segs: Vec<(PathSeg, f64)> = bp.segments().map(|s| (s, s.arclen(1e-3))).collect(); + let total = segs.iter().map(|s| s.1).sum(); + PathWarp { segs, total, flip } + } + + pub fn length(&self) -> f64 { + self.total + } + + /// Point and tangent angle (radians) at arc length `s` (clamped to the path). + pub fn at(&self, s: f64) -> Option<(Point, f64)> { + let s = if self.flip { self.total - s } else { s }; + let mut s = s.clamp(0.0, self.total); + let n = self.segs.len(); + for (i, (seg, len)) in self.segs.iter().enumerate() { + if s <= *len || i + 1 == n { + let t = if *len > 1e-9 { seg.inv_arclen(s.min(*len), 1e-4) } else { 0.0 }; + let p = seg.eval(t); + let d = match seg { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + }; + let mut a = d.y.atan2(d.x); + if self.flip { + a += std::f64::consts::PI; + } + return Some((p, a)); + } + s -= len; + } + None + } + + /// Transform for something laid out on a straight line (x along the line, y down from the + /// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with + /// the path, its line at `baseline` on the path. + pub fn glyph(&self, mid: f64, baseline: f64) -> Option { + let (p, a) = self.at(mid)?; + Some(Affine::translate(p.to_vec2()) * Affine::rotate(a) * Affine::translate((-mid, -baseline))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_and_angles_along_a_path() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + bp.line_to((100.0, 100.0)); + let w = PathWarp::new(&bp, false); + assert!((w.length() - 200.0).abs() < 1e-6); + let (p, a) = w.at(150.0).unwrap(); + assert!((p - Point::new(100.0, 50.0)).hypot() < 1e-6); + assert!((a - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + // A glyph's centre (x = 50 on its line, baseline at y = 10) lands on the path at 50. + let g = w.glyph(50.0, 10.0).unwrap(); + assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9); + let flipped = PathWarp::new(&bp, true); + assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 63ccf49..358981a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -515,7 +515,17 @@ impl Exporter<'_> { } Content::Text(tfr) => { let cs = self.cache.get(doc, tfr.story, page_name); - if let Some(ft) = cs.frame(it.id) { + if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { + // Type on a path: the glyphs as outlines along the path. + for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { + let st = &cs.styles[si as usize]; + if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) { + s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.draw_path(&p); + s.set_fill(None); + } + } + } else if let Some(ft) = cs.frame(it.id) { let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or(""); self.frame_text(s, &cs, ft, text); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 02c2bad..d87dde6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -469,7 +469,10 @@ impl Renderer { Content::Text(tf) => { let cs = self.story(f, tf.story, page_name); if let Some(ft) = cs.frame(it.id) { - self.draw_text(ctx, f, &cs, ft, xf); + match &tf.options.path { + Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), + None => self.draw_text(ctx, f, &cs, ft, xf), + } } } _ => {} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 55abeaa..8247e74 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -330,3 +330,36 @@ fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { _ => st, } } + +impl Renderer { + /// Type on a path: glyphs follow the frame's path. + #[allow(clippy::too_many_arguments)] + pub(crate) fn draw_path_text( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + cs: &Arc, + ft: &FrameText, + xf: Affine, + path: &BezPath, + pt: &designcraft_doc::PathType, + ) { + let doc = f.doc; + ctx.set_transform(f.view * xf); + for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { + let st = &cs.styles[si as usize]; + if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_path(&bp); + } + if st.stroke != designcraft_color::swatch::NONE + && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + { + ctx.set_paint(color_of(&c, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); + ctx.stroke_path(&bp); + } + self.stats.glyphs += 1; + } + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index fcffe24..36b4a01 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -274,6 +274,7 @@ pub fn create(id: &str) -> Box { "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), "page" => Box::new(nav::PageTool), + "typeOnPath" => Box::new(nav::PathTypeTool), other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))), } } diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 41b4201..50173e1 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -172,3 +172,27 @@ impl Tool for PageTool { Cursor::Arrow } } + +/// Type on a Path tool (Shift+T): click a path to put type on it; typing goes there. +#[derive(Default)] +pub struct PathTypeTool; + +impl Tool for PathTypeTool { + fn id(&self) -> &'static str { + "typeOnPath" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if ev.kind != PointerKind::Up { + return vec![]; + } + let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; + let Some(it) = cx.doc.item(id) else { return vec![] }; + match &it.content { + designcraft_doc::Content::Unassigned => vec![Action::Exec("type.onPath".into(), json!({"id": id.0})), Action::SwitchTool("type".into())], + _ => vec![], + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Text + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 51b02c6..ed04ef9 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -214,3 +214,24 @@ fn page_tool_opens_page_size() { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); } + +#[test] +fn type_on_path_tool_targets_paths() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new( + id, + lid, + designcraft_doc::Shape::GraphicLine, + designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 100.0), designcraft_geom::Point::new(300.0, 100.0)), + ); + it.stroke = designcraft_doc::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.offset(SpreadRef::Doc(0)); + let mut t = create("typeOnPath"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x, 100.0 + off.y)); + assert_eq!(a, vec![Action::Exec("type.onPath".into(), serde_json::json!({"id": id.0})), Action::SwitchTool("type".into())]); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 271c9b1..6ec88d3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -176,6 +176,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.findFontDialog", "-", "cmd:type.createOutlines", + ">Type on a Path", + "cmd:type.pathOptions", + "cmd:type.pathOptions|Delete Type from Path|{\"delete\": true}", + "<", "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "cmd:type.fillWithPlaceholder", ">Insert Special Character", diff --git a/docs/parity.md b/docs/parity.md index 15541c1..8742c28 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 60 | 26 | 11 | 75% | +| P1 | 97 | 61 | 26 | 10 | 76% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -23,7 +23,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Fill, stroke, colour | 76% | | Effects & transparency | 50% | | Placing & links | 78% | -| Type & text frames | 82% | +| Type & text frames | 85% | | Typography | 85% | | Styles | 92% | | Tables | 81% | @@ -185,7 +185,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Type & text frames | Threading | P0 | D | | Type & text frames | Overset detection & indicator | P0 | D | | Type & text frames | Text Frame Options | P0 | D | -| Type & text frames | Type on a path | P1 | M | +| Type & text frames | Type on a path | P1 | D | | Type & text frames | Vertical type | P2 | M | | Type & text frames | Story Editor | P1 | D | | Type & text frames | Track Changes | P2 | M |