diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index eaed0e1c6..cde3a3a70 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -858,6 +858,8 @@ mod tests { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "type.attachToPath" + | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" | "layer.vectorMask.edit" diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fc586e3c2..6f7836fa4 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -209,13 +209,18 @@ pub struct ParagraphRun { } /// Point text grows from its anchor (text-space origin = first baseline at the anchor); -/// paragraph (box) text wraps inside a rectangle. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +/// paragraph (box) text wraps inside a rectangle; path text runs along a curve. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TextShape { #[default] Point, /// Text-space pixels, before the layer transform. Box { x: f32, y: f32, width: f32, height: f32 }, + /// Type on a path: one line laid out like point text, then each glyph is placed on `path` + /// (text-space pixels, before the layer transform) by arc length and turned to follow the + /// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip` + /// puts the text on the other side of the path. + Path { path: super::vector::Subpath, start: f32, flip: bool }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index a11db9976..1e7556b1d 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -44,6 +44,7 @@ fn has_point_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Point => Ok(()), TextShape::Box { .. } => Err("the type layer is already paragraph text".into()), + TextShape::Path { .. } => Err("detach the type layer from its path first".into()), } } @@ -51,6 +52,7 @@ fn has_box_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Box { .. } => Ok(()), TextShape::Point => Err("the type layer is already point text".into()), + TextShape::Path { .. } => Err("the type layer is path text".into()), } } @@ -279,7 +281,7 @@ fn contours(els: &[PathEl]) -> Vec> { /// which reproduces the nonzero fill under Photoshop's per-subpath path operations. pub fn text_path(doc: &Document, t: &TextLayer) -> Path { let l = layout(doc, t); - let warp = photocraft_text::render::layout_warp(&l, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&l, t); let mut subpaths = Vec::new(); for glyph in photocraft_text::render::outlines(&l, &t.transform, warp.as_ref()) { let cs = contours(&glyph); @@ -321,6 +323,95 @@ fn convert_to_shape(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "subpaths": n})) } +// ---------- type on a path ---------- + +/// Largest anchor count of a path that type can follow (a hostile document cannot make the +/// layout walk millions of segments). +const MAX_TYPE_PATH_KNOTS: usize = 100_000; + +/// The subpath `type.attachToPath` follows, in document space: the work path (default) or a +/// shape layer's path (`"shape":id`), subpath `"subpath"` (default 0). +fn path_source(doc: &Document, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let path = match p.get("shape") { + None | Some(Value::Null) => doc.work_path.as_ref().ok_or_else(|| bad("the document has no work path; draw a path or pass \"shape\":layerId"))?, + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad("\"shape\" must be a layer id"))?; + match doc.layer(LayerId(id)).map(|l| &l.content) { + Some(LayerContent::Shape(sh)) => &sh.path, + Some(_) => return Err(bad("\"shape\" is not a shape layer")), + None => return Err(EngineError::NoLayer(LayerId(id))), + } + } + }; + let index = match p.get("subpath") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("\"subpath\" must be a non-negative index"))?, + }; + let sub = path.subpaths.get(index).ok_or_else(|| bad("the path has no such subpath"))?; + if sub.knots.len() < 2 { + return Err(bad("the subpath needs at least two anchor points")); + } + if sub.knots.len() > MAX_TYPE_PATH_KNOTS { + return Err(bad("the subpath has too many anchor points")); + } + Ok(sub.clone()) +} + +fn attach_to_path(s: &mut Session, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let start = match p.get("start") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("\"start\" must be a finite number of pixels"))? as f32, + }; + let flip = match p.get("flip") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, + }; + let id = layer_param(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + if doc.effective_locks(id).all { + return Err(EngineError::Other("the type layer is locked".into())); + } + let sub = path_source(doc, p)?; + with_text(s, p, "Type on a Path", |t, _| { + // The path lives in document space; the type layer keeps it in its own text space, so + // the type lands on the path where it is drawn. + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let knots = sub.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + let path = Subpath { closed: sub.closed, knots, op: PathOp::Combine }; + if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { + return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); + } + t.shape = TextShape::Path { path, start, flip }; + Ok(()) + })?; + Ok(json!({"layer": id.0, "start": start, "flip": flip})) +} + +fn is_path_text(s: &Session) -> std::result::Result<(), String> { + match active_text(s)?.shape { + TextShape::Path { .. } => Ok(()), + _ => Err("the type layer is not on a path".into()), + } +} + +fn detach_from_path(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + with_text(s, p, "Release Type from Path", |t, _| { + if !matches!(t.shape, TextShape::Path { .. }) { + return Err(EngineError::Other("the type layer is not on a path".into())); + } + // Point text starts where the type started on the path. + let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); + t.shape = TextShape::Point; + t.transform = then_translate(&t.transform, origin.0, origin.1); + Ok(()) + })?; + Ok(json!({"layer": id.0})) +} + // ---------- warp ---------- fn warp_text(s: &mut Session, p: &Value) -> Result { @@ -588,6 +679,15 @@ pub fn specs() -> Vec { has_format_target, warp_text ), + spec!( + "type.attachToPath", + "Type on a Path", + &[], + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + has_text, + attach_to_path + ), + spec!("type.detachFromPath", "Release Type from Path", &[], LP, is_path_text, detach_from_path), spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)), spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts( s, diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 395128a44..c0c091b10 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -283,6 +283,36 @@ fn rasterize_type_layer() { assert!(!s.is_enabled("type.rasterizeTypeLayer")); } +#[test] +fn type_attaches_to_a_path_and_releases_it() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + assert!(s.is_enabled("type.attachToPath")); + assert!(!s.is_enabled("type.detachFromPath")); + // No work path yet, a bad subpath and bad params are errors, not panics. + assert!(s.execute("type.attachToPath", json!({})).is_err()); + let flat = ink(&s, id); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + assert!(s.execute("type.attachToPath", json!({"subpath": 5})).is_err()); + assert!(s.execute("type.attachToPath", json!({"start": "far"})).is_err()); + assert!(s.execute("type.attachToPath", json!({"shape": 9999})).is_err()); + s.execute("type.attachToPath", json!({"start": 4})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0)); + let on_path = ink(&s, id); + assert!(on_path.height() > on_path.width(), "the type runs down the vertical path: {on_path:?}"); + assert!(on_path.height() > flat.height()); + assert!(!s.is_enabled("type.convertToPointText")); + s.execute("type.detachFromPath", json!({})).unwrap(); + assert_eq!(text(&s, id).shape, TextShape::Point); + assert!(ink(&s, id).width() > ink(&s, id).height()); + assert!(s.undo()); + assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 47ea5383d..a26219033 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -140,7 +140,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&t.psd_raw, sink), runs: t.runs.clone(), paragraphs: t.paragraphs.clone(), - shape: t.shape, + shape: t.shape.clone(), orientation: t.orientation, antialias: t.antialias, warp: t.warp.clone(), @@ -459,7 +459,7 @@ impl Loader<'_> { psd_raw: self.opt_blob(psd_raw)?, runs: runs.clone(), paragraphs: paragraphs.clone(), - shape: *shape, + shape: shape.clone(), orientation: *orientation, antialias: *antialias, warp: warp.clone(), diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 42dbfae5c..8b74a00c4 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -541,6 +541,27 @@ fn text_kerning_roundtrips_and_defaults() { assert!(s.contains("\"kern\":0.0"), "kern defaults to 0"); } +/// Type on a path (#218): the path, start offset and flip survive .pcraft. +#[test] +fn path_text_shape_roundtrips() { + use photocraft_doc::text::TextShape; + use photocraft_doc::vector::Subpath; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let path = Subpath::polyline(&[(0.0, 0.0), (50.0, 20.0), (120.0, 5.5)]); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + for id in ids { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true }; + } + } + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.shape.clone()), + _ => None, + }); + assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); +} + #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9159f704d..595c887be 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -11,7 +11,7 @@ pub(crate) const MAX_RENDER_PIXELS: u64 = 64 * 1024 * 1024; /// Renders `t` from its model unless its ink would exceed [`MAX_RENDER_PIXELS`]. fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: f32, format: photocraft_color::PixelFormat) { let layout = eng.layout(t, dpi); - let warp = photocraft_text::render::layout_warp(&layout, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&layout, t); let ink = photocraft_text::render::ink_rect_warped(&layout, &t.transform, warp.as_ref()); if u64::from(ink.width()).saturating_mul(u64::from(ink.height())) > MAX_RENDER_PIXELS { t.cache = Some(photocraft_raster::Surface::new(photocraft_color::PixelFormat { alpha: true, ..format })); diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 95e7ddfa1..8fabe8be2 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -261,7 +261,7 @@ fn imported_type_layers_keep_their_full_bounds() { // Model: size in points at the document resolution, transform and shape as written. assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt); assert_eq!(t.transform, c.transform, "{}", c.name); - match (c.box_bounds, t.shape) { + match (c.box_bounds, t.shape.clone()) { (None, TextShape::Point) => {} (Some(b), TextShape::Box { x, y, width, height }) => { assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name) diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/psd_vertical_type.rs index e69f725da..93cd1a885 100644 --- a/crates/io/tests/psd_vertical_type.rs +++ b/crates/io/tests/psd_vertical_type.rs @@ -34,7 +34,7 @@ fn text(doc: &Document) -> &TextLayer { #[test] fn vertical_orientation_round_trips_through_psd() { for shape in [TextShape::Point, TextShape::Box { x: -200.0, y: 0.0, width: 220.0, height: 300.0 }] { - let doc = doc_with(shape); + let doc = doc_with(shape.clone()); let ink = text(&doc).cache.as_ref().unwrap().content_bounds(); assert!(ink.height() > ink.width(), "{shape:?}: {ink:?}"); let out = photocraft_io::export(&doc, "v.psd", &Default::default()).unwrap(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fdbb8a01..720347d62 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -483,7 +483,8 @@ impl Layouter { let text = &t.text; let (box_rect, is_box) = match t.shape { TextShape::Box { x, y, width, height } => ((x, y, width, height), true), - TextShape::Point => ((0.0, 0.0, 0.0, 0.0), false), + // Path text is laid out as one point-text line, then bent onto the path. + TextShape::Point | TextShape::Path { .. } => ((0.0, 0.0, 0.0, 0.0), false), }; let mut prev_baseline: Option = None; let mut pending_space = 0.0f32; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7dccbd..64db53ece 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -125,7 +125,7 @@ impl TextEngine { /// Lays out and rasterizes a text layer into document space. pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) { let l = self.layout(layer, dpi); - let warp = render::layout_warp(&l, layer.warp.as_ref()); + let warp = render::layout_deform(&l, layer); let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); (l, r) } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 27b190951..8a915c2d0 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -244,7 +244,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { - TextShape::Point => { + TextShape::Point | TextShape::Path { .. } => { let p = arr_f(frame.get("0")); if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } } @@ -260,7 +260,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { // the frame folded into its transform and its `bounds` near the origin: applying the frame // again would move it twice. let origin = match layer.shape { - TextShape::Point => Point::new(0.0, 0.0), + TextShape::Point | TextShape::Path { .. } => Point::new(0.0, 0.0), TextShape::Box { .. } => { let b = engine_data(&t.text) .map(|e| { @@ -1183,7 +1183,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E dict.set("UseFractionalGlyphWidths", E::Bool(true)); } let (shape_type, box_bounds) = match layer.shape { - TextShape::Point => (0, None), + // PSD path type is not written yet: the layer saves as point text. + TextShape::Point | TextShape::Path { .. } => (0, None), TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); @@ -1296,8 +1297,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }, ), ); - let [l, t, r, b] = match (layer.shape, ink) { - (TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], + let [l, t, r, b] = match (&layer.shape, ink) { + (&TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], (_, Some(i)) => i, _ => [0.0; 4], }; diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index b50a63fe0..af29dd10d 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -10,7 +10,7 @@ use skrifa::{GlyphId, MetadataProvider}; use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; -use crate::warp::Warp; +use crate::warp::{Placement, Warp}; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; @@ -90,7 +90,19 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); + let mut glyph = glyph_xform(layout, g, face.skew_deg); + // Type on a path moves each glyph rigidly: turned to the curve at its centre. + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; let single = [(0.0, 0.0)]; @@ -107,7 +119,7 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); - match warp { + match bend { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; let mut pen = Pen::new(&mut ws, local); @@ -161,6 +173,22 @@ fn glyph_xform(layout: &TextLayout, g: &PlacedGlyph, face_skew_deg: f32) -> Xfor } } +/// The horizontal centre (text space) of a placed glyph: the pen position plus half its advance. +fn glyph_center(layout: &TextLayout, g: &PlacedGlyph, font: &skrifa::FontRef, size_px: f32, coords: &[NormalizedCoord]) -> f64 { + let hs = layout.styles.get(g.style as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + let advance = match g.orient { + GlyphOrient::Horizontal => font.glyph_metrics(Size::new(size_px), LocationRef::new(coords)).advance_width(GlyphId::new(g.id)).unwrap_or(0.0), + _ => 0.0, + }; + f64::from(g.x) + f64::from(advance * hs) / 2.0 +} + +/// The deformation of a type layer: the path it runs along, or its Warp Text settings (None +/// when neither applies). +pub fn layout_deform(layout: &TextLayout, layer: &photocraft_doc::TextLayer) -> Option { + Warp::on_path(&layer.shape).or_else(|| layout_warp(layout, layer.warp.as_ref())) +} + /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option { Warp::new(warp?, layout.bounds()?) @@ -347,8 +375,19 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); - let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() }; + let mut glyph = glyph_xform(layout, g, face.skew_deg); + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } + let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { glyphs.push(rec.out); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 832f0f0d8..2dc890544 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -152,7 +152,7 @@ fn tabs_advance_to_the_next_default_tab_stop() { #[test] fn psd_round_trips_tabs() { for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { - let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let t = TextLayer { shape: shape.clone(), ..point("Name\tValue\n\tIndented", 20.0) }; let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); assert_eq!(back.text, t.text, "{shape:?}"); } @@ -1892,3 +1892,65 @@ fn engine_data_antialias_numbers_match_photoshop() { assert_eq!(back.antialias, mode, "{n}"); } } + +/// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs +/// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. +#[test] +fn type_on_a_path_follows_the_curve() { + use photocraft_doc::vector::{Knot, Subpath}; + let fmt = PixelFormat::RGBA8; + let mut eng = TextEngine::new(); + let ink = |eng: &mut TextEngine, t: &TextLayer| { + let (_, r) = eng.render(t, 72.0, fmt); + r.rect + }; + let straight = point("Path text", 24.0); + let flat = ink(&mut eng, &straight); + assert!(flat.width() > flat.height() * 3, "{flat:?}"); + + let mut down = straight.clone(); + down.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 400.0)]), start: 0.0, flip: false }; + let r = ink(&mut eng, &down); + assert!(r.height() > r.width() * 3, "vertical path gives tall ink: {r:?}"); + assert!(r.height() >= flat.width() - 8, "arc length is preserved: {r:?} vs {flat:?}"); + + // A half circle bends the text through a larger area than the straight line. + let k = 0.5523 * 60.0; + let arc = Subpath { + closed: false, + knots: vec![ + Knot { + anchor: photocraft_geom::Point::new(-60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(-60.0, 0.0), + out_ctrl: photocraft_geom::Point::new(-60.0, -k), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(0.0, -60.0), + in_ctrl: photocraft_geom::Point::new(-k, -60.0), + out_ctrl: photocraft_geom::Point::new(k, -60.0), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -k), + out_ctrl: photocraft_geom::Point::new(60.0, 0.0), + smooth: true, + }, + ], + ..Default::default() + }; + let mut curved = straight.clone(); + curved.shape = TextShape::Path { path: arc.clone(), start: 0.0, flip: false }; + let c = ink(&mut eng, &curved); + assert!(c.height() > flat.height() * 2, "curved ink is taller: {c:?} vs {flat:?}"); + + // A path too short for the text hides the glyphs past its end. + let mut short = straight.clone(); + short.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (30.0, 0.0)]), start: 0.0, flip: false }; + assert!(ink(&mut eng, &short).width() < flat.width() / 2); + // A degenerate path falls back to straight point text instead of failing. + let mut dot = straight.clone(); + dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; + assert_eq!(ink(&mut eng, &dot), flat); +} diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index e056bdff3..f148a18e1 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -6,7 +6,8 @@ //! formulas), the identity at `bend = 0` and continuous in `bend`. The mapping is evaluated per //! point; the renderer subdivides long segments so straight edges bend. -use photocraft_doc::text::TextWarp; +use photocraft_doc::text::{TextShape, TextWarp}; +use photocraft_doc::vector::Subpath; use photocraft_geom::warp::{StyleWarp, WarpStyle}; /// Warp styles in Photoshop's menu order: (PSD `warpStyle` value, short id used by commands). @@ -45,10 +46,29 @@ pub fn short_style(psd: &str) -> Option<&'static str> { STYLES.iter().find(|(p, _)| *p == psd).map(|(_, short)| *short) } +/// Where a glyph goes when the layout is bent: see [`Warp::place_glyph`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + /// Not path text: bend the outline point by point with [`Warp::apply`]. + Bend, + /// Beyond either end of the path: the glyph is not drawn. + Hidden, + /// Path text: the glyph keeps its shape and is moved rigidly by this text-space map + /// (`[a, b, c, d, e, f]`, applied to text-space points). + Frame([f64; 6]), +} + /// A warp prepared for one layout: the shared style math of -/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box. -#[derive(Clone, Copy, Debug)] -pub struct Warp(StyleWarp); +/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box, +/// or a path the type runs along. +#[derive(Clone, Debug)] +pub struct Warp(Kind); + +#[derive(Clone, Debug)] +enum Kind { + Style(Box), + Path(PathFrame), +} impl Warp { /// Prepares `w` for text whose bounds are `[x0, y0, x1, y1]` (text space). `None` for @@ -56,17 +76,117 @@ impl Warp { pub fn new(w: &TextWarp, bounds: [f32; 4]) -> Option { let style = WarpStyle::parse(&w.style).filter(|s| s.is_preset() && s.psd_name() == w.style)?; StyleWarp::new(style, f64::from(w.value), f64::from(w.horizontal_distortion), f64::from(w.vertical_distortion), !w.horizontal, bounds.map(f64::from)) - .map(Warp) + .map(|s| Warp(Kind::Style(Box::new(s)))) + } + + /// The deformation of type on a path (`None` for any other shape, or a path without length). + pub fn on_path(shape: &TextShape) -> Option { + let TextShape::Path { path, start, flip } = shape else { return None }; + PathFrame::new(path, *start, *flip).map(|f| Warp(Kind::Path(f))) } /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. pub fn max_segment(&self) -> f64 { - self.0.max_segment() + match &self.0 { + Kind::Style(s) => s.max_segment(), + Kind::Path(_) => 2.0, + } } /// Maps a text-space point. pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { - self.0.apply(x, y) + match &self.0 { + Kind::Style(s) => s.apply(x, y), + Kind::Path(p) => p.apply(x, y), + } + } + + /// How to place a glyph whose horizontal centre is at text-space `cx`. + pub fn place_glyph(&self, cx: f64) -> Placement { + match &self.0 { + Kind::Style(_) => Placement::Bend, + Kind::Path(p) => p.frame(cx).map_or(Placement::Hidden, Placement::Frame), + } + } +} + +/// Most cubic segments of a type path that are followed (a hostile file cannot make this large). +const MAX_PATH_SEGMENTS: usize = 4096; + +/// A flattened path with arc lengths: text-space `x` is arc length from `start`, `y` the +/// distance to the left of the direction of travel (the side the glyph tops point to). +#[derive(Clone, Debug)] +struct PathFrame { + pts: Vec<(f64, f64)>, + /// Arc length at each point. + cum: Vec, + start: f64, +} + +impl PathFrame { + fn new(path: &Subpath, start: f32, flip: bool) -> Option { + let mut pts: Vec<(f64, f64)> = Vec::new(); + for seg in path.segments().iter().take(MAX_PATH_SEGMENTS) { + let p = seg.map(|q| (q.x, q.y)); + if p.iter().any(|q| !q.0.is_finite() || !q.1.is_finite()) { + return None; + } + let poly = (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1) + (p[2].0 - p[1].0).hypot(p[2].1 - p[1].1) + (p[3].0 - p[2].0).hypot(p[3].1 - p[2].1); + let n = ((poly / 2.0).ceil() as usize).clamp(4, 128); + if pts.is_empty() { + pts.push(p[0]); + } + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + pts.push((a * p[0].0 + b * p[1].0 + c * p[2].0 + d * p[3].0, a * p[0].1 + b * p[1].1 + c * p[2].1 + d * p[3].1)); + } + } + // Drop repeated points so every piece has a direction. + pts.dedup_by(|b, a| (b.0 - a.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9); + if flip { + pts.reverse(); + } + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + acc += (p.0 - q.0).hypot(p.1 - q.1); + } + cum.push(acc); + } + (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { pts, cum, start: f64::from(start) }) + } + + fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. + fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { + let last = self.pts.len() - 1; + let i = self.cum.partition_point(|&c| c < s).clamp(1, last); + let (a, b) = (self.pts[i - 1], self.pts[i]); + let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); + let t = ((b.0 - a.0) / len, (b.1 - a.1) / len); + let along = s - self.cum[i - 1]; + ((a.0 + t.0 * along, a.1 + t.1 * along), t) + } + + fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let (p, t) = self.at(self.start + x); + (p.0 - t.1 * y, p.1 + t.0 * y) + } + + /// The rigid map for a glyph centred at text-space `cx`; `None` outside the path. + fn frame(&self, cx: f64) -> Option<[f64; 6]> { + let s = self.start + cx; + if !(0.0..=self.length()).contains(&s) { + return None; + } + let (p, t) = self.at(s); + Some([t.0, t.1, -t.1, t.0, p.0 - t.0 * cx, p.1 - t.1 * cx]) } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 6a7c81608..3238674ad 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -184,7 +184,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) { } pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { - match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? { + match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()))? { photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)), _ => None, } @@ -1013,7 +1013,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if crate::type_transform::draw(app, painter, xf, mods) { return; } - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index d99334cba..784162b25 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -85,7 +85,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { return None; } let LayerContent::Text(text) = &layer.content else { return None }; - let shape = text.shape; + let shape = text.shape.clone(); let size = text.size_pt; let (layout, aff, _) = crate::type_tool::layout(app, id)?; if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() { diff --git a/docs/scorecard.md b/docs/scorecard.md index 80c047bb4..6741f981e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -10,7 +10,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | -| [Type](#type) | 4 | 2 | 3 | | +| [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | @@ -210,7 +210,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | diff --git a/scorecard/type.toml b/scorecard/type.toml index 078c956a3..ee8a876a4 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -7,9 +7,9 @@ order = 5 [[item]] id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" -status = "missing" +status = "partial" issue = 218 -note = "doc text.rs:209 TextShape has Point and Box only" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2"