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