mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
* 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:
19 files changed
+416
-35
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user