Type: type on a path, glyphs follow the curve (#4365) (#4377)

* Type: type on a path, glyphs follow the curve (#4365)

Add TextShape::Path (path, start offset, flip). The text is laid out as one
point-text line and each glyph is placed rigidly on the flattened path by arc
length, turned to the tangent at its centre; glyphs past the end are hidden and
a degenerate path falls back to straight text. New commands type.attachToPath
(work path or shape layer subpath) and type.detachFromPath. TextShape is no
longer Copy. .pcraft round-trips the new variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 23:11:09 -04:00
1 parent e8af9d592f
commit 15aac8d7b6
19 files changed
+416 -35

No files matched your search

+2
View File
@@ -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"
+7 -2
View File
@@ -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)]
+101 -1
View File
@@ -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<Vec<Knot>> {
/// 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<Value> {
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<Subpath> {
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<Value> {
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<Value> {
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<Value> {
@@ -588,6 +679,15 @@ pub fn specs() -> Vec<CommandSpec> {
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,
@@ -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.
+2 -2
View File
@@ -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(),
+21
View File
@@ -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<LayerId> = 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};
+1 -1
View File
@@ -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 }));
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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();
+2 -1
View File
@@ -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<f32> = None;
let mut pending_space = 0.0f32;
+1 -1
View File
@@ -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)
}
+6 -5
View File
@@ -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<E>, 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<u8>
},
),
);
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],
};
+44 -5
View File
@@ -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<NormalizedCoord> = 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> {
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> {
Warp::new(warp?, layout.bounds()?)
@@ -347,8 +375,19 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
continue;
};
let coords: Vec<NormalizedCoord> = 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);
+63 -1
View File
@@ -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);
}
+127 -7
View File
@@ -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<StyleWarp>),
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<Warp> {
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<Warp> {
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<f64>,
start: f64,
}
impl PathFrame {
fn new(path: &Subpath, start: f32, flip: bool) -> Option<PathFrame> {
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])
}
}
+2 -2
View File
@@ -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 }) => {
+1 -1
View File
@@ -85,7 +85,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option<TransformSession> {
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() {
+2 -2
View File
@@ -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 |
+2 -2
View File
@@ -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"