mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 17:32:42 -06:00
Merge pull request #6648 from dotCooCoo/feat/dynamic-text-on-path
Type: Dynamic Text sizes type on a path to fill its brackets
This commit is contained in:
@@ -77,6 +77,12 @@ pub struct TextObject {
|
||||
/// them up round the inside.
|
||||
#[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub path_spacing: f64,
|
||||
/// Type › Dynamic Text (type on a path only): the layout scales the text by one factor so it
|
||||
/// fills the span between its start and end brackets, and fits it again after each edit. The
|
||||
/// stored sizes keep their proportions; the layout's `fit_scale` is the factor drawn. Off for new
|
||||
/// type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "dynamicText", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub dynamic_text: bool,
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
|
||||
@@ -89,6 +89,7 @@ impl TextObject {
|
||||
path_effect: PathEffect::default(),
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
dynamic_text: false,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: TypeAntiAlias::Sharp,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
/// Dynamic Text is off for new type and for type saved before it, and saved only when on.
|
||||
#[test]
|
||||
fn dynamic_text_is_saved_only_when_on() {
|
||||
let t = TextObject::point(Point::ZERO, "Hi", CharStyle::default());
|
||||
assert!(!t.dynamic_text);
|
||||
let off = serde_json::to_value(&t).unwrap();
|
||||
assert!(off.get("dynamicText").is_none());
|
||||
assert!(!serde_json::from_value::<TextObject>(off).unwrap().dynamic_text);
|
||||
let on = TextObject { dynamic_text: true, ..t };
|
||||
let saved = serde_json::to_value(&on).unwrap();
|
||||
assert_eq!(saved["dynamicText"], true);
|
||||
assert!(serde_json::from_value::<TextObject>(saved).unwrap().dynamic_text);
|
||||
}
|
||||
|
||||
/// Character Direction is saved only when set, and styles written before it existed read as
|
||||
/// Default.
|
||||
#[test]
|
||||
|
||||
@@ -631,6 +631,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"type.convertToAreaType",
|
||||
"type.convertToPointType",
|
||||
"type.createOutlines",
|
||||
"type.dynamicText",
|
||||
"type.fillPlaceholder",
|
||||
"type.fitHeadline",
|
||||
"type.insert",
|
||||
|
||||
@@ -1623,6 +1623,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
dynamic_text: false,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
@@ -1914,6 +1915,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_effect: Default::default(),
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
dynamic_text: false,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
anti_alias: Default::default(),
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create",
|
||||
["Type", "Threaded Text"],
|
||||
None,
|
||||
"{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order → {ids}",
|
||||
"{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order; a frame with Dynamic Text has it turned off and keeps the sizes it was drawn at → {ids, dynamicTextOff?: [id…] (the frames it was turned off on; left out when none)}",
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Thread Text",
|
||||
[],
|
||||
None,
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three); a frame with Dynamic Text has it turned off and keeps the sizes it was drawn at → {id, dynamicTextOff?: [id…] (the frames it was turned off on; left out when none)}; the two frames are selected",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
@@ -126,6 +126,11 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
#[cfg(test)]
|
||||
COPIED.with(|c| c.set(c.get() + 1));
|
||||
// Type on a path in a thread is not Dynamic Text: its frames keep the sizes it draws them
|
||||
// at, before the story is taken from them.
|
||||
if thread.iter().filter(|id| text_of(doc, **id).is_some()).count() >= 2 {
|
||||
dynamic_text_off(doc, &thread);
|
||||
}
|
||||
// The story: live frames' runs, plus the text of frames deleted by this edit.
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
let mut sources: Vec<TextObject> = vec![];
|
||||
@@ -264,15 +269,28 @@ fn thread_setting<T: PartialEq>(before: &Document, doc: &Document, live: &[NodeI
|
||||
changed.or(live.first()).and_then(now)
|
||||
}
|
||||
|
||||
/// Turn Auto Size off for the frames of each thread of two or more frames, as [`reflow`] does,
|
||||
/// without re-flowing the story: for a document restored from a file as it was saved, so a later
|
||||
/// change to a frame's settings keeps the frame's size.
|
||||
/// Turn Dynamic Text off on the frames among `ids` that have it on, each keeping the sizes it is
|
||||
/// drawn at ([`super::typemenu::bake_dynamic_text`]); the frames it turned off.
|
||||
pub(crate) fn dynamic_text_off(doc: &mut Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let on: Vec<NodeId> = ids.iter().copied().filter(|id| text_of(doc, *id).is_some_and(|t| t.dynamic_text)).collect();
|
||||
for id in &on {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) {
|
||||
super::typemenu::bake_dynamic_text(t);
|
||||
}
|
||||
}
|
||||
on
|
||||
}
|
||||
|
||||
/// Turn Auto Size and Dynamic Text off for the frames of each thread of two or more frames, as
|
||||
/// [`reflow`] does, without re-flowing the story: for a document restored from a file as it was
|
||||
/// saved, so a later change to a frame's settings keeps the frame's size.
|
||||
pub(crate) fn keep_frame_sizes(doc: &mut Document) {
|
||||
for thread in doc.text_threads.clone() {
|
||||
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
||||
if live.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
dynamic_text_off(doc, &live);
|
||||
for id in live {
|
||||
if text_of(doc, id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
|
||||
&& let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind)
|
||||
@@ -534,6 +552,8 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "select area type and closed paths (at least one area type object)"));
|
||||
}
|
||||
let out = ids.clone();
|
||||
// The frames that lose Dynamic Text: those of the new thread and the threads it takes in.
|
||||
let off: Vec<u64> = with_threads(d, &ids).into_iter().filter(|i| text_of(d, *i).is_some_and(|t| t.dynamic_text)).map(|i| i.0).collect();
|
||||
s.edit("Thread Text", |d, _| {
|
||||
for id in &ids {
|
||||
if text_of(d, *id).is_none() {
|
||||
@@ -562,7 +582,11 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
touch(d, ids[0]);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
let mut reply = json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||||
if !off.is_empty() {
|
||||
reply["dynamicTextOff"] = json!(off);
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// `text.thread.add`: the loaded text of a port, placed.
|
||||
@@ -573,7 +597,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = switch_or(p, "before", false, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?;
|
||||
let style = src.first_style();
|
||||
// A new frame takes the style `from` is drawn in: threading turns its Dynamic Text off.
|
||||
let style = vectorcraft_text::drawn(src).first_style();
|
||||
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
||||
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
||||
(Some(v), None, None) => {
|
||||
@@ -610,6 +635,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
||||
};
|
||||
let appearance = d.node(from).map(|n| n.appearance.clone());
|
||||
// The frames that lose Dynamic Text: those of `from`'s thread and `to`.
|
||||
let off: Vec<u64> = with_threads(d, &[from, to]).into_iter().filter(|i| text_of(d, *i).is_some_and(|t| t.dynamic_text)).map(|i| i.0).collect();
|
||||
let id = s.edit("Thread Text", |d, sel| {
|
||||
let to = match new {
|
||||
Some(t) => {
|
||||
@@ -645,7 +672,11 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([from, to]);
|
||||
Ok(to)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
let mut reply = json!({ "id": id.0 });
|
||||
if !off.is_empty() {
|
||||
reply["dynamicTextOff"] = json!(off);
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// An empty frame of `kind` (area type or type on a path) placed by `xf`, with the type options of
|
||||
@@ -654,6 +685,7 @@ fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, kind: TextKi
|
||||
let mut t = src.clone();
|
||||
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
||||
t.set_all_paras(src.para_at(0).clone());
|
||||
t.dynamic_text = false;
|
||||
t.kind = kind;
|
||||
t.xf = xf;
|
||||
refresh_bounds(&mut t);
|
||||
|
||||
@@ -60,6 +60,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
cmd!(
|
||||
"type.dynamicText",
|
||||
"Dynamic Text",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool} turn Dynamic Text on or off for the selected type on a path (on omitted: the opposite of the first object's setting). On, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. Off, the text keeps the sizes it was drawn at. Point type and area type in the selection are left as they are; a selection holding type on a path in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When every object already has the setting, nothing changes and no undo step is recorded → {on, ids, fitScale (the first object's factor, 1 when off)}",
|
||||
has_free_path_type,
|
||||
dynamic_text
|
||||
),
|
||||
cmd!(
|
||||
"type.fillPlaceholder",
|
||||
"Fill with Placeholder Text",
|
||||
@@ -511,6 +520,86 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
options(s)
|
||||
}
|
||||
|
||||
/// Whether type on a path `id` is a frame of a thread.
|
||||
fn threaded(d: &Document, id: NodeId) -> bool {
|
||||
!super::threads::story_of(d, id).is_empty()
|
||||
}
|
||||
|
||||
/// `type.dynamicText` is enabled for a selection holding type on a path, none of it in a thread.
|
||||
fn has_free_path_type(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let d = &st.doc;
|
||||
let selected: Vec<NodeId> = st.selection.objects.to_vec();
|
||||
let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
|
||||
if paths.is_empty() {
|
||||
return Err("select type on a path".into());
|
||||
}
|
||||
let frames = threaded_ids(d, &paths);
|
||||
if !frames.is_empty() {
|
||||
return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none.
|
||||
fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
|
||||
ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting) on the
|
||||
/// selected type on a path, in one undo step, and none when every object already has it. Turning
|
||||
/// it off keeps the sizes the text was drawn at ([`bake_dynamic_text`]).
|
||||
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.dynamicText";
|
||||
let (ids, on, changing) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path"))?;
|
||||
let frames = threaded_ids(d, &ids);
|
||||
if !frames.is_empty() {
|
||||
return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}")));
|
||||
}
|
||||
let on = switch_or(p, "on", !path_text(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
|
||||
let changing: Vec<NodeId> = ids.iter().copied().filter(|id| path_text(d.node(*id)).is_some_and(|t| t.dynamic_text != on)).collect();
|
||||
(ids, on, changing)
|
||||
};
|
||||
if !changing.is_empty() {
|
||||
s.edit("Dynamic Text", |d, _| {
|
||||
for id in &changing {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
if on {
|
||||
t.dynamic_text = true;
|
||||
refresh_bounds(t);
|
||||
} else {
|
||||
bake_dynamic_text(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let fit =
|
||||
ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale);
|
||||
Ok(json!({"on": on, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fitScale": fit}))
|
||||
}
|
||||
|
||||
/// Turn Dynamic Text off on `t`, keeping the sizes its layout draws it at: every run's size,
|
||||
/// leading and baseline shifts take the fit factor ([`vectorcraft_text::scaled`]). False when it
|
||||
/// was already off.
|
||||
pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool {
|
||||
if !t.dynamic_text {
|
||||
return false;
|
||||
}
|
||||
let f = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale;
|
||||
if f.is_finite() && f != 1.0 {
|
||||
*t = vectorcraft_text::scaled(t, f);
|
||||
}
|
||||
t.dynamic_text = false;
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
|
||||
// ---------- placeholder / insert ----------
|
||||
|
||||
/// VectorCraft's own placeholder copy (not the classical Latin text).
|
||||
|
||||
@@ -99,13 +99,17 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) ->
|
||||
NodeKind::Text(t) => {
|
||||
v["text"] = json!(t.plain_text());
|
||||
// Area type and type on a path: does the text fit (the red overflow "+")? Area type
|
||||
// also reports its fit and Shrink Text's factor (1 unshrunk).
|
||||
// also reports its fit and Shrink Text's factor (1 unshrunk), and type on a path its
|
||||
// Dynamic Text setting and factor (1 when off).
|
||||
if !matches!(t.kind, vectorcraft_doc::TextKind::Point) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
v["overflow"] = json!(lay.overflow);
|
||||
if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) {
|
||||
v["fit"] = json!(t.area.fit.id());
|
||||
v["fitScale"] = json!(lay.fit_scale);
|
||||
} else {
|
||||
v["dynamicText"] = json!(t.dynamic_text);
|
||||
v["fitScale"] = json!(lay.fit_scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ mod docsetup;
|
||||
mod document_move;
|
||||
mod draw2;
|
||||
mod dynamic_symbols;
|
||||
mod dynamic_text;
|
||||
mod editcolors;
|
||||
mod effectedit;
|
||||
mod emptytype;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Type › Dynamic Text (`type.dynamicText`): type on a path scaled by one factor to fill the span
|
||||
//! between its brackets, fitted again after each edit, and kept at its drawn sizes when it is
|
||||
//! turned off or threaded.
|
||||
//! <https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html>
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextObject};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// 12 pt type on the 400 pt line from (100, 300) to (500, 300), from its start, selected.
|
||||
fn on_line(s: &mut Session, text: &str) -> NodeId {
|
||||
let pid = s.execute("path.create", &json!({ "d": "M100 300 L500 300" })).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": text, "size": 12, "at": [100, 300]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn fit(s: &Session, id: NodeId) -> f64 {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).fit_scale
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn err(r: Result<Value>) -> String {
|
||||
r.unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_turns_on_and_off_and_toggles() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Grow");
|
||||
let before = undo_len(&s);
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id.0])));
|
||||
let f = r["fitScale"].as_f64().unwrap();
|
||||
assert!(f > 2.0, "short text on a long path grows: {f}");
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text");
|
||||
assert!(text(&s, id).dynamic_text);
|
||||
// The bounds the engine keeps are the fitted layout's.
|
||||
let t = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert_eq!(t.cached_bounds, Some(lay.bounds));
|
||||
// Already on: nothing changes and no undo step is recorded.
|
||||
let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(r["on"], json!(true));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
// Toggled off: the text keeps the sizes it was drawn at, in one undo step.
|
||||
let r = s.execute("type.dynamicText", &json!({})).unwrap();
|
||||
assert_eq!((r["on"].clone(), r["fitScale"].clone()), (json!(false), json!(1.0)));
|
||||
assert_eq!(undo_len(&s), before + 2);
|
||||
let off = text(&s, id);
|
||||
assert!(!off.dynamic_text);
|
||||
assert!((off.runs[0].style.size - 12.0 * f).abs() < 1e-9, "{} vs {}", off.runs[0].style.size, 12.0 * f);
|
||||
// Undo brings Dynamic Text back with the stored size.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let back = text(&s, id);
|
||||
assert!(back.dynamic_text && back.runs[0].style.size == 12.0);
|
||||
let r = s.execute("type.dynamicText", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["on"], json!(false));
|
||||
let r = s.execute("type.dynamicText", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["on"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_refuses_bad_params_and_other_type() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Grow");
|
||||
let before = undo_len(&s);
|
||||
assert!(err(s.execute("type.dynamicText", &json!({"on": "yes"}))).contains("on"));
|
||||
assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on"));
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
// Point type and area type alone: refused.
|
||||
let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
let area =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap();
|
||||
for other in [point, area] {
|
||||
s.execute("select.set", &json!({"ids": [other]})).unwrap();
|
||||
assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path"));
|
||||
}
|
||||
let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [empty]})).unwrap();
|
||||
assert!(s.execute("type.dynamicText", &json!({})).is_err());
|
||||
// With point type selected too, only the type on a path changes.
|
||||
let before = undo_len(&s);
|
||||
s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap();
|
||||
let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([id.0]));
|
||||
assert!(!text(&s, NodeId(point)).dynamic_text);
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_fits_again_after_each_edit() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Grow");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let start = fit(&s, id);
|
||||
// Longer text draws smaller.
|
||||
s.execute("text.setText", &json!({"ids": [id.0], "text": "Grow and grow again"})).unwrap();
|
||||
let longer = fit(&s, id);
|
||||
assert!(longer < start * 0.5, "{longer} vs {start}");
|
||||
// An end bracket nearer the start draws it smaller still.
|
||||
s.execute("type.pathOptions", &json!({"ids": [id.0], "end": 0.5})).unwrap();
|
||||
let half = fit(&s, id);
|
||||
assert!(half < longer * 0.6, "{half} vs {longer}");
|
||||
s.execute("type.pathOptions", &json!({"ids": [id.0], "end": null})).unwrap();
|
||||
// A longer path draws it larger.
|
||||
s.execute("text.reshapePath", &json!({"id": id.0, "anchors": [[0, 1]], "dx": 200, "dy": 0})).unwrap();
|
||||
let reshaped = fit(&s, id);
|
||||
assert!(reshaped > longer * 1.3, "{reshaped} vs {longer}");
|
||||
// Each fit keeps the cached bounds in step with the layout.
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.cached_bounds, Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds));
|
||||
// Scaling the object: the fit is in document points, so the drawn size stays.
|
||||
let drawn = |s: &Session| {
|
||||
let t = text(s, id);
|
||||
t.runs[0].style.size * t.style_scale().unwrap().points * fit(s, id)
|
||||
};
|
||||
let size = drawn(&s);
|
||||
s.execute("object.scale", &json!({"ids": [id.0], "sx": 50, "sy": 50})).unwrap();
|
||||
let smaller = drawn(&s);
|
||||
assert!(smaller < size * 0.6, "the path is half as long: {smaller} vs {size}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threading_turns_dynamic_text_off_at_its_drawn_sizes() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Threaded story");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let f = fit(&s, id);
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": 100, "y": 400, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0, frame]})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let r = s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!(r["dynamicTextOff"], json!([id.0]));
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let t = text(&s, id);
|
||||
assert!(!t.dynamic_text);
|
||||
assert!((t.runs[0].style.size - 12.0 * f).abs() < 1e-9, "{} vs {}", t.runs[0].style.size, 12.0 * f);
|
||||
// Undo restores Dynamic Text and the stored size together.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && t.runs[0].style.size == 12.0);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(!text(&s, id).dynamic_text);
|
||||
// Type on a path in a thread is refused by id, alone or with other type on a path.
|
||||
let e = err(s.execute("type.dynamicText", &json!({"on": true, "ids": [id.0]})));
|
||||
assert!(e.contains("in a thread") && e.contains(&id.0.to_string()), "{e}");
|
||||
let free = on_line(&mut s, "Free");
|
||||
let e = err(s.execute("type.dynamicText", &json!({"on": true, "ids": [free.0, id.0]})));
|
||||
assert!(e.contains("in a thread") && e.contains(&id.0.to_string()), "{e}");
|
||||
assert!(!text(&s, id).dynamic_text && !text(&s, free).dynamic_text);
|
||||
// A thread made without any Dynamic Text says nothing about it.
|
||||
let a = on_line(&mut s, "Plain");
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let r = s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert!(r.get("dynamicTextOff").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_thread_add_turns_dynamic_text_off() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Threaded story");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let f = fit(&s, id);
|
||||
let r = s.execute("text.thread.add", &json!({"from": id.0, "rect": [100, 400, 200, 100]})).unwrap();
|
||||
assert_eq!(r["dynamicTextOff"], json!([id.0]));
|
||||
let t = text(&s, id);
|
||||
assert!(!t.dynamic_text && (t.runs[0].style.size - 12.0 * f).abs() < 1e-9);
|
||||
let added = text(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
assert!(!added.dynamic_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_is_saved_only_when_on_and_reads_back() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Saved");
|
||||
let data =
|
||||
|s: &mut Session| s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
let off = data(&mut s);
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let f = fit(&s, id);
|
||||
let on = data(&mut s);
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "on.vectorcraft", "dataBase64": on})).unwrap();
|
||||
let t = first_text(&o);
|
||||
assert!(text(&o, t).dynamic_text);
|
||||
assert_eq!(text(&o, t).runs[0].style.size, 12.0);
|
||||
assert_eq!(fit(&o, t), f);
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "off.vectorcraft", "dataBase64": off})).unwrap();
|
||||
let t = first_text(&o);
|
||||
assert!(!text(&o, t).dynamic_text);
|
||||
// The document summary reports the setting and the factor.
|
||||
let d = s.execute("document.inspect", &json!({})).unwrap().to_string();
|
||||
assert!(d.contains("\"dynamicText\":true"), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_export_writes_the_drawn_size() {
|
||||
let mut s = session();
|
||||
let id = on_line(&mut s, "Grow");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let size = 12.0 * fit(&s, id);
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(svg.contains("<textPath"), "{svg}");
|
||||
let sizes: Vec<f64> = svg
|
||||
.match_indices("font-size")
|
||||
.filter_map(|(at, m)| {
|
||||
let rest = svg[at + m.len()..].trim_start_matches(['=', ':', '"', ' ']);
|
||||
let end = rest.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
})
|
||||
.collect();
|
||||
assert!(sizes.iter().any(|v| (v - size).abs() < 1e-3), "{size} in {sizes:?}");
|
||||
assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}");
|
||||
}
|
||||
|
||||
/// The id of the first text object in `s`'s document.
|
||||
fn first_text(s: &Session) -> NodeId {
|
||||
let mut found = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if found.is_none() && matches!(n.kind, NodeKind::Text(_)) {
|
||||
found = Some(n.id);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
}
|
||||
@@ -4338,6 +4338,36 @@ fn scaled_area_type_and_type_on_a_path_stay_type() {
|
||||
assert!(!program(&doc).contains("/AI11Text"));
|
||||
}
|
||||
|
||||
/// Type on a path with Dynamic Text is written as type at the sizes it is drawn at, with a warning
|
||||
/// that the setting is not written.
|
||||
#[test]
|
||||
fn dynamic_text_is_written_at_its_drawn_sizes() {
|
||||
use vectorcraft_doc::{TextKind, TextObject};
|
||||
let mut doc = Document::new(600.0, 300.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK));
|
||||
t.kind = TextKind::OnPath {
|
||||
path: shapes::line(vectorcraft_geom::Point::new(50.0, 200.0), vectorcraft_geom::Point::new(550.0, 200.0)),
|
||||
start: 0.0,
|
||||
end: None,
|
||||
};
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.dynamic_text = true;
|
||||
let drawn = 12.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).fit_scale;
|
||||
assert!(drawn > 50.0, "{drawn}");
|
||||
let node = text_node(&mut doc, t);
|
||||
doc.insert(Some(layer), usize::MAX, node).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let texts: Vec<&TextObject> =
|
||||
back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
|
||||
assert_eq!(texts.len(), 1);
|
||||
assert!(!texts[0].dynamic_text);
|
||||
let k = texts[0].xf.as_coeffs()[0];
|
||||
assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn));
|
||||
}
|
||||
|
||||
/// See-through type with the knockout shape flag comes back with the flag and writes no warning.
|
||||
#[test]
|
||||
fn the_knockout_shape_of_type_comes_back() {
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches
|
||||
pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps";
|
||||
pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps";
|
||||
const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps";
|
||||
pub(super) const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at in the editing data for other apps";
|
||||
|
||||
/// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]),
|
||||
/// the opacity, Isolate Blending, Knockout Group (0 off, 1 on, 2 neutral) and Opacity & Mask Define
|
||||
@@ -1203,6 +1204,9 @@ impl<'a> Writer<'a> {
|
||||
/// names the story of its thread, which its first frame holds.
|
||||
fn text(&mut self, n: &Node, xy: Xy) {
|
||||
let NodeKind::Text(t) = &n.kind else { return };
|
||||
// Dynamic Text is written as the sizes its layout draws the type at.
|
||||
let fitted = t.dynamic_text.then(|| vectorcraft_text::drawn(t));
|
||||
let t = fitted.as_deref().unwrap_or(&**t);
|
||||
let doc = self.doc;
|
||||
let thread = doc.text_threads.iter().find(|th| th.contains(&n.id));
|
||||
// Type that can't be a stub is written as its outlines, which count their own objects.
|
||||
@@ -1231,6 +1235,9 @@ impl<'a> Writer<'a> {
|
||||
if lost {
|
||||
self.warn(texts::TYPE_ATTRIBUTES);
|
||||
}
|
||||
if fitted.is_some() {
|
||||
self.warn(DYNAMIC_TEXT);
|
||||
}
|
||||
// The stub takes the transparency set before it, as a path does. Its isolation and knockout
|
||||
// are read only from a state block after it, as a group's are.
|
||||
self.set_object_xy(xy.object());
|
||||
|
||||
@@ -688,12 +688,9 @@ impl<'a> Writer<'a> {
|
||||
if lost_attributes(t) {
|
||||
self.warn(TYPE_ATTRIBUTES);
|
||||
}
|
||||
// Area type that Shrink Text to Fit scales down is written in the styles it draws in.
|
||||
let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. }))
|
||||
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale)
|
||||
.filter(|f| *f != 1.0)
|
||||
.map(|f| vectorcraft_text::scaled(t, f));
|
||||
let t = fitted.as_ref().unwrap_or(t);
|
||||
// Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in.
|
||||
let fitted = vectorcraft_text::drawn(t);
|
||||
let t = &*fitted;
|
||||
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let (kind, path, start) = match &t.kind {
|
||||
TextKind::Point => (0, None, 0.0),
|
||||
|
||||
@@ -609,12 +609,9 @@ impl Rules<'_> {
|
||||
if n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
|
||||
art.unsupported("fills or strokes on the type object");
|
||||
}
|
||||
// Area type that Shrink Text to Fit scales down is written in the styles it draws in.
|
||||
let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. }))
|
||||
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale)
|
||||
.filter(|f| *f != 1.0)
|
||||
.map(|f| vectorcraft_text::scaled(t, f));
|
||||
let t = fitted.as_ref().unwrap_or(t);
|
||||
// Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in.
|
||||
let fitted = vectorcraft_text::drawn(t);
|
||||
let t = &*fitted;
|
||||
let st = t.first_style();
|
||||
if t.runs.iter().any(|r| r.style != st) {
|
||||
self.warn("type with several character styles is written in its first one");
|
||||
|
||||
@@ -534,8 +534,11 @@ impl Writer<'_> {
|
||||
// The <text> element's user space is the document's; gradients span the laid-out text.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
self.note_fonts(t, &lay);
|
||||
// The styles the layout draws the text in: Dynamic Text scales its sizes, leading and baseline shifts.
|
||||
let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale));
|
||||
let drawn = fitted.as_ref().unwrap_or(t);
|
||||
let space = (lay.bounds, self.xf * t.xf);
|
||||
let base = self.char_props(&t.first_style(), space);
|
||||
let base = self.char_props(&drawn.first_style(), space);
|
||||
let mut props = base.clone();
|
||||
let (from, to) = t.kind.path_span().unwrap_or((start, 1.0));
|
||||
// The anchor sits where the layout aligns the line within its span.
|
||||
@@ -556,7 +559,7 @@ impl Writer<'_> {
|
||||
let a = self.attrs(&props);
|
||||
let offset = fmt_num(at * 100.0, 3);
|
||||
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
|
||||
for run in &t.runs {
|
||||
for run in &drawn.runs {
|
||||
let mut diff = run_diff(&base, self.char_props(&run.style, space));
|
||||
diff.extend(direction_props(&run.style));
|
||||
let a = self.attrs(&diff);
|
||||
|
||||
@@ -37,9 +37,9 @@ pub(crate) use bidi::{is_rtl, para_bidi};
|
||||
use breaking::tab_advance;
|
||||
#[cfg(test)]
|
||||
use breaking::{break_line, candidates};
|
||||
pub use driver::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled};
|
||||
#[cfg(test)]
|
||||
pub(crate) use driver::layout_faces_per_call;
|
||||
pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
|
||||
pub(crate) use driver::{fit_passes, layout_faces_per_call};
|
||||
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -209,24 +209,105 @@ const SHRINK_PASSES: usize = 11;
|
||||
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
|
||||
#[cfg(any(test, feature = "test-fonts"))]
|
||||
crate::test_fonts::count_layout();
|
||||
// The sizes the fits step and limit are the ones the Character panel shows (`shown`), whatever
|
||||
// em size a font height reference sets them at.
|
||||
let shown = t;
|
||||
let resolved = db.font_heights_resolved(t);
|
||||
let t = &*resolved;
|
||||
let first = layout_once(db, t, opts);
|
||||
match (opts.fit.min_scale(), &t.kind) {
|
||||
(Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first),
|
||||
(Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, shown, opts, min).unwrap_or(first),
|
||||
(_, TextKind::OnPath { .. }) if t.dynamic_text => fill_path(db, t, shown, opts, &first).unwrap_or(first),
|
||||
_ => first,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document size of the first character of `t` (its run's size; an empty run before it draws
|
||||
/// nothing), in tenths of a point. None when it is not a usable size.
|
||||
fn first_size_tenths(t: &TextObject) -> Option<f64> {
|
||||
let points = t.style_scale()?.points;
|
||||
let run = t.runs.iter().find(|r| !r.text.is_empty()).or(t.runs.first());
|
||||
run.map(|r| r.style.size * points * 10.0).filter(|s| s.is_finite() && *s > 0.0)
|
||||
}
|
||||
|
||||
/// Most layout passes Dynamic Text runs past the first (a guess, a search out from it, bisection).
|
||||
const DYNAMIC_PASSES: usize = 11;
|
||||
|
||||
/// The sizes, in tenths of a document point, Dynamic Text keeps the first run's size within: 0.1
|
||||
/// to 1,296 pt, the Character panel's range.
|
||||
const DYNAMIC_TENTHS: (f64, f64) = (1.0, 12_960.0);
|
||||
|
||||
/// Dynamic Text for type on a path: the largest scale at which the text does not run past its end
|
||||
/// bracket, so that it fills the span between its brackets. The first run's document size moves in
|
||||
/// steps of 0.1 pt within 0.1 to 1,296 pt; at either limit the text keeps that size, and at the
|
||||
/// smallest it may still overflow. Each guess is the size tried last times the span's length over
|
||||
/// the length the text took there, which is exact for a straight span with no Spacing; a guess
|
||||
/// outside the sizes still open gives way to a step out from the last one (doubling) or to
|
||||
/// bisection. At most [`DYNAMIC_PASSES`] passes. `t` is laid out; the sizes are those of `shown`,
|
||||
/// `t` before its font height references were resolved. None (keep `first`) when the text takes no
|
||||
/// length, the span has none, or the text has no usable size.
|
||||
fn fill_path(db: &FontDb, t: &TextObject, shown: &TextObject, opts: &LayoutOptions, first: &TextLayout) -> Option<TextLayout> {
|
||||
let tenths = first_size_tenths(shown)?;
|
||||
let (min, max) = DYNAMIC_TENTHS;
|
||||
// The size in tenths that would fill the span, from a layout at `k` tenths.
|
||||
let guess = |k: f64, l: &TextLayout| -> Option<f64> {
|
||||
let (used, room) = l.path_fill?;
|
||||
let g = (k * room / used).floor();
|
||||
(used.is_finite() && used > 1e-9 && room.is_finite() && room > 1e-9 && g.is_finite()).then(|| g.clamp(min, max))
|
||||
};
|
||||
let mut next = guess(tenths, first)?;
|
||||
let at = |k: f64| -> TextLayout {
|
||||
#[cfg(test)]
|
||||
FIT_PASSES.set(FIT_PASSES.get() + 1);
|
||||
let f = k / tenths;
|
||||
let mut l = layout_once(db, &scaled(t, f), opts);
|
||||
l.fit_scale = f;
|
||||
l
|
||||
};
|
||||
// The largest size tried that fits, and the smallest that overflows, each with its layout.
|
||||
let mut fits: Option<(f64, TextLayout)> = None;
|
||||
let mut over: Option<(f64, TextLayout)> = None;
|
||||
let mut step = 1.0;
|
||||
for _ in 0..DYNAMIC_PASSES {
|
||||
let l = at(next);
|
||||
let guessed = guess(next, &l);
|
||||
if l.overflow {
|
||||
if over.as_ref().is_none_or(|(o, _)| next < *o) {
|
||||
over = Some((next, l));
|
||||
}
|
||||
} else if fits.as_ref().is_none_or(|(f, _)| next > *f) {
|
||||
fits = Some((next, l));
|
||||
}
|
||||
// The sizes still open lie strictly between the largest that fits and the smallest that
|
||||
// overflows, within the limits.
|
||||
let lo = fits.as_ref().map_or(min - 1.0, |f| f.0);
|
||||
let hi = over.as_ref().map_or(max + 1.0, |o| o.0);
|
||||
if hi - lo <= 1.0 {
|
||||
break;
|
||||
}
|
||||
next = match guessed.filter(|g| *g > lo && *g < hi) {
|
||||
Some(g) => g,
|
||||
None if fits.is_some() && over.is_some() => (lo + (hi - lo) * 0.5).floor(),
|
||||
// Everything tried fits: try larger. Everything tried overflows: try smaller.
|
||||
None if fits.is_some() => (lo + step).min(hi - 1.0),
|
||||
None => (hi - step).max(lo + 1.0),
|
||||
};
|
||||
step *= 2.0;
|
||||
}
|
||||
// The largest that fits, or at the smallest size (or at the pass limit) the smallest tried.
|
||||
fits.or(over).map(|(_, l)| l)
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
|
||||
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
|
||||
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
|
||||
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
|
||||
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
|
||||
/// still overflowing. `t` is laid out; the sizes are those of `shown`, `t` before its font height
|
||||
/// references were resolved. None (keep the full-size layout) when the text has no usable size.
|
||||
fn shrink_to_fit(db: &FontDb, t: &TextObject, shown: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
|
||||
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
|
||||
// same document points as the Character controls, independent of that representation.
|
||||
let points = t.style_scale()?.points;
|
||||
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
let points = shown.style_scale()?.points;
|
||||
let size = shown.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
if !(min.is_finite() && min < 1.0) {
|
||||
return None;
|
||||
}
|
||||
@@ -292,6 +373,31 @@ pub fn scaled(t: &TextObject, f: f64) -> TextObject {
|
||||
s
|
||||
}
|
||||
|
||||
/// `t` in the styles its layout draws it in: [`scaled`] by the layout's
|
||||
/// [`TextLayout::fit_scale`] when Shrink Text to Fit (area type) or Dynamic Text (type on a path)
|
||||
/// scales it, `t` itself otherwise. Text with neither setting is not laid out.
|
||||
pub fn drawn(t: &TextObject) -> std::borrow::Cow<'_, TextObject> {
|
||||
let fits = match &t.kind {
|
||||
TextKind::Area { .. } => t.area.fit.min_scale().is_some(),
|
||||
TextKind::OnPath { .. } => t.dynamic_text,
|
||||
TextKind::Point => false,
|
||||
};
|
||||
let f = if fits { layout(FontDb::global(), t).fit_scale } else { 1.0 };
|
||||
if f != 1.0 { std::borrow::Cow::Owned(scaled(t, f)) } else { std::borrow::Cow::Borrowed(t) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many layouts Dynamic Text ran past the first on this thread ([`fit_passes`]).
|
||||
static FIT_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many layouts Dynamic Text ran past the first on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fit_passes() -> usize {
|
||||
FIT_PASSES.get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces.
|
||||
|
||||
@@ -295,6 +295,8 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
.count()
|
||||
};
|
||||
let (mut steps, mut s0, mut hung) = arrange(&sg);
|
||||
// What the whole text takes along the path and what the span holds: Dynamic Text's first guess.
|
||||
cx.out.path_fill = Some((steps.iter().sum(), avail + hung.0 + hung.1));
|
||||
// Where the text that fits ends: before the first glyph that doesn't, which threaded text
|
||||
// flows on from, as from the last line of area type.
|
||||
let mut fits = text_len;
|
||||
|
||||
@@ -48,7 +48,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
|
||||
|
||||
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled};
|
||||
pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled};
|
||||
pub use shape::spacing_scale;
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
@@ -370,9 +370,14 @@ pub struct TextLayout {
|
||||
pub on_path: bool,
|
||||
/// Area type: the frame cells text flowed into (one per row/column).
|
||||
pub frames: Vec<Rect>,
|
||||
/// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled
|
||||
/// by to fit its frame (1.0 when the text is not shrunk).
|
||||
/// Shrink Text to Fit and Dynamic Text: the factor the text's sizes, leading and baseline
|
||||
/// shifts were scaled by to fit its frame or the span of its path (1.0 when the text is not
|
||||
/// scaled). Shrink Text to Fit keeps it at most 1; Dynamic Text may take it above 1.
|
||||
pub fit_scale: f64,
|
||||
/// Type on a path: the length the arranged glyphs take along the path, and the length of the
|
||||
/// span between the brackets with what hangs outside them ([`Self::fit_scale`]'s first guess
|
||||
/// for Dynamic Text). `None` for other type and for a path with no length.
|
||||
pub path_fill: Option<(f64, f64)>,
|
||||
/// Inline graphics, in text order.
|
||||
pub inlines: Vec<InlineGlyph>,
|
||||
/// List markers, in text order.
|
||||
@@ -392,6 +397,7 @@ impl Default for TextLayout {
|
||||
on_path: false,
|
||||
frames: Vec::new(),
|
||||
fit_scale: 1.0,
|
||||
path_fill: None,
|
||||
inlines: Vec::new(),
|
||||
markers: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Area type fitting: Shrink Text to Fit.
|
||||
//! Area type fitting (Shrink Text to Fit) and type on a path fitting (Dynamic Text).
|
||||
|
||||
use crate::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{AreaFit, CharStyle, TextKind};
|
||||
use vectorcraft_doc::{AreaFit, CharStyle, TextKind, TextRun};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \
|
||||
@@ -67,6 +67,18 @@ fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
|
||||
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A size that measures the cap height shrinks in steps of 0.1 pt of the size the Character panel
|
||||
/// shows, not of the em size the layout sets it at.
|
||||
#[test]
|
||||
fn shrink_text_steps_the_size_the_character_panel_shows() {
|
||||
let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(30.0));
|
||||
t.runs[0].style.font_height_reference = vectorcraft_doc::text::FontHeightReference::CapHeight;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale);
|
||||
let shown = 14.0 * l.fit_scale;
|
||||
assert!((shown * 10.0 - (shown * 10.0).round()).abs() < 1e-6, "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_text_respects_its_minimum() {
|
||||
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
|
||||
@@ -174,3 +186,182 @@ fn shrink_text_follows_top_to_top_leading_and_character_alignment() {
|
||||
}
|
||||
assert!(layout(db(), &bigger).overflow);
|
||||
}
|
||||
|
||||
/// Type on a path `path` set at `size`, with Dynamic Text on.
|
||||
fn dynamic_on(text: &str, size: f64, path: &kurbo::BezPath, end: Option<f64>) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(path), start: 0.0, end };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.dynamic_text = true;
|
||||
t
|
||||
}
|
||||
|
||||
/// Type on a straight path `len` points long, set at `size`, with Dynamic Text on.
|
||||
fn dynamic(text: &str, size: f64, len: f64) -> TextObject {
|
||||
dynamic_on(text, size, &kurbo::Line::new((0.0, 100.0), (len, 100.0)).to_path(0.1), None)
|
||||
}
|
||||
|
||||
/// `t` laid out at `f` times its sizes with Dynamic Text off.
|
||||
fn fixed_at(t: &TextObject, f: f64) -> TextLayout {
|
||||
let mut s = scaled(t, f);
|
||||
s.dynamic_text = false;
|
||||
layout(db(), &s)
|
||||
}
|
||||
|
||||
/// Checks that Dynamic Text laid `t` out at the largest tenth of a point that fits: the first run's
|
||||
/// drawn size is whole tenths, the layout is that of `t` at that size, and a tenth more overflows.
|
||||
fn assert_fills(t: &TextObject, what: &str) -> TextLayout {
|
||||
let l = layout(db(), t);
|
||||
let size = t.runs[0].style.size * l.fit_scale;
|
||||
assert!((size * 10.0 - (size * 10.0).round()).abs() < 1e-6, "{what}: {size}");
|
||||
assert!(!l.overflow, "{what}: fits at {size}");
|
||||
let same = fixed_at(t, l.fit_scale);
|
||||
assert_eq!((same.glyphs.len(), same.overflow), (l.glyphs.len(), false), "{what}");
|
||||
assert!(fixed_at(t, (size + 0.1) / t.runs[0].style.size).overflow, "{what}: {size} + 0.1 pt still fits");
|
||||
l
|
||||
}
|
||||
|
||||
/// Dynamic Text: short text grows and long text shrinks to fill the span of its path, at the
|
||||
/// largest size in tenths of a point that does not run past the end bracket, and the line then
|
||||
/// takes the span's length within one step.
|
||||
/// <https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html>
|
||||
#[test]
|
||||
fn dynamic_text_fills_the_span_of_its_path() {
|
||||
let short = dynamic("Fill me", 12.0, 400.0);
|
||||
let mut off = short.clone();
|
||||
off.dynamic_text = false;
|
||||
assert_eq!(layout(db(), &off).fit_scale, 1.0, "off, the text keeps its size");
|
||||
let l = assert_fills(&short, "short");
|
||||
assert!(l.fit_scale > 2.0, "short text grows: {}", l.fit_scale);
|
||||
let (used, room) = l.path_fill.unwrap_or_default();
|
||||
let size = 12.0 * l.fit_scale;
|
||||
assert!(used <= room + 1e-6 && room - used <= used * 0.1 / size * 1.5 + 1e-6, "{used} of {room} at {size}");
|
||||
let long = dynamic(COPY, 12.0, 400.0);
|
||||
assert!(fixed_at(&long, 1.0).overflow);
|
||||
let l = assert_fills(&long, "long");
|
||||
assert!(l.fit_scale < 1.0, "long text shrinks: {}", l.fit_scale);
|
||||
// Deterministic.
|
||||
assert_eq!(layout(db(), &long).fit_scale, l.fit_scale);
|
||||
}
|
||||
|
||||
/// Dynamic Text refits to the brackets: an end bracket nearer the start makes the text smaller, and
|
||||
/// a closed path with no end bracket holds the text once round.
|
||||
/// <https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html>
|
||||
#[test]
|
||||
fn dynamic_text_follows_its_brackets() {
|
||||
let line = kurbo::Line::new((0.0, 100.0), (400.0, 100.0)).to_path(0.1);
|
||||
let whole = layout(db(), &dynamic_on("Brackets", 12.0, &line, None));
|
||||
let half = dynamic_on("Brackets", 12.0, &line, Some(0.5));
|
||||
let l = assert_fills(&half, "half");
|
||||
assert!(l.fit_scale < whole.fit_scale * 0.6, "{} vs {}", l.fit_scale, whole.fit_scale);
|
||||
let circle = kurbo::Circle::new((200.0, 200.0), 100.0).to_path(0.1);
|
||||
let round = dynamic_on("Round and round the path", 12.0, &circle, None);
|
||||
let l = assert_fills(&round, "circle");
|
||||
let (used, room) = l.path_fill.unwrap_or_default();
|
||||
assert!((room - std::f64::consts::TAU * 100.0).abs() < 1.0, "{room}");
|
||||
assert!(used > room * 0.95, "{used} of {room}");
|
||||
}
|
||||
|
||||
/// The runs keep their proportions: one factor scales every size.
|
||||
#[test]
|
||||
fn dynamic_text_keeps_the_runs_proportions() {
|
||||
let mut t = dynamic("Small", 10.0, 400.0);
|
||||
t.runs.push(TextRun::new(" LARGE", CharStyle { size: 20.0, ..CharStyle::default() }));
|
||||
let l = assert_fills(&t, "two runs");
|
||||
let d = drawn(&t);
|
||||
assert!((d.runs[1].style.size / d.runs[0].style.size - 2.0).abs() < 1e-9);
|
||||
assert!((d.runs[0].style.size - 10.0 * l.fit_scale).abs() < 1e-9);
|
||||
// A size Dynamic Text does not scale is drawn as stored.
|
||||
let mut point = t.clone();
|
||||
point.dynamic_text = false;
|
||||
assert!(matches!(drawn(&point), std::borrow::Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
/// Nothing to fit: empty text, a path with no length and a span with no length lay out as they are.
|
||||
/// Past the size limits the text stays at 0.1 pt (and overflows) or at 1,296 pt.
|
||||
#[test]
|
||||
fn dynamic_text_edges_and_limits() {
|
||||
let empty = layout(db(), &dynamic("", 12.0, 400.0));
|
||||
assert_eq!((empty.fit_scale, empty.overflow), (1.0, false));
|
||||
let spaces = assert_fills(&dynamic(" ", 12.0, 400.0), "spaces");
|
||||
assert!(spaces.fit_scale > 1.0);
|
||||
let one = assert_fills(&dynamic("W", 12.0, 100.0), "one character");
|
||||
assert!(one.fit_scale > 1.0);
|
||||
let point = kurbo::Line::new((50.0, 50.0), (50.0, 50.0)).to_path(0.1);
|
||||
let l = layout(db(), &dynamic_on("No room", 12.0, &point, None));
|
||||
assert_eq!((l.fit_scale, l.overflow), (1.0, true));
|
||||
let line = kurbo::Line::new((0.0, 100.0), (400.0, 100.0)).to_path(0.1);
|
||||
let mut closed_span = dynamic_on("No span", 12.0, &line, Some(0.5));
|
||||
closed_span.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.5, end: Some(0.5) };
|
||||
let l = layout(db(), &closed_span);
|
||||
assert_eq!((l.fit_scale, l.overflow), (1.0, true));
|
||||
// A span of a hundredth of a point: down at 0.1 pt the text still overflows.
|
||||
let tiny = layout(db(), &dynamic(COPY, 12.0, 0.01));
|
||||
assert!(tiny.overflow && (12.0 * tiny.fit_scale - 0.1).abs() < 1e-9, "{}", 12.0 * tiny.fit_scale);
|
||||
// A span of 100,000 points: one letter stops at 1,296 pt.
|
||||
let huge = layout(db(), &dynamic("i", 12.0, 100_000.0));
|
||||
assert!(!huge.overflow && (12.0 * huge.fit_scale - 1296.0).abs() < 1e-9, "{}", 12.0 * huge.fit_scale);
|
||||
// The limits apply to the first character's size, past an empty run before it.
|
||||
let mut empty_first = dynamic("", 0.1, 100_000.0);
|
||||
empty_first.runs.push(TextRun::new("i", CharStyle { size: 100.0, ..CharStyle::default() }));
|
||||
let l = layout(db(), &empty_first);
|
||||
assert!((100.0 * l.fit_scale - 1296.0).abs() < 1e-9, "{}", 100.0 * l.fit_scale);
|
||||
// A size that measures the cap height steps and stops as the Character panel shows it.
|
||||
let mut cap = dynamic("i", 12.0, 100_000.0);
|
||||
cap.runs[0].style.font_height_reference = vectorcraft_doc::text::FontHeightReference::CapHeight;
|
||||
let l = layout(db(), &cap);
|
||||
assert!((12.0 * l.fit_scale - 1296.0).abs() < 1e-9, "{}", 12.0 * l.fit_scale);
|
||||
let cap_short =
|
||||
TextObject { kind: dynamic("Cap height", 12.0, 400.0).kind, runs: vec![TextRun::new("Cap height", cap.runs[0].style.clone())], ..cap };
|
||||
let l = layout(db(), &cap_short);
|
||||
let shown = 12.0 * l.fit_scale;
|
||||
assert!(!l.overflow && (shown * 10.0 - (shown * 10.0).round()).abs() < 1e-6, "{shown}");
|
||||
// The same document size whatever the local size: a 12 pt run stored at 0.012 under a scale of 1,000.
|
||||
let mut scaled_xf = dynamic("Fill me", 0.012, 0.4);
|
||||
scaled_xf.xf = Affine::scale(1000.0);
|
||||
let l = layout(db(), &scaled_xf);
|
||||
let document_size = 12.0 * l.fit_scale;
|
||||
assert!(!l.overflow && (document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6, "{document_size}");
|
||||
}
|
||||
|
||||
/// Right-to-left text, vertical type, a bulleted paragraph, Optical Margin Alignment and Spacing
|
||||
/// fit without overflowing, through the layout's own overflow rule.
|
||||
#[test]
|
||||
fn dynamic_text_fits_every_kind_of_line() {
|
||||
let arc = {
|
||||
let mut p = kurbo::BezPath::new();
|
||||
p.move_to((0.0, 200.0));
|
||||
p.quad_to((200.0, 0.0), (400.0, 200.0));
|
||||
p
|
||||
};
|
||||
let rtl = dynamic_on("שלום עולם", 12.0, &arc, None);
|
||||
assert!(assert_fills(&rtl, "right to left").fit_scale > 1.0);
|
||||
let mut vertical = dynamic_on("Vertical type", 12.0, &kurbo::Line::new((0.0, 0.0), (0.0, 400.0)).to_path(0.1), None);
|
||||
vertical.vertical = true;
|
||||
assert!(assert_fills(&vertical, "vertical").fit_scale > 1.0);
|
||||
let mut bullet = dynamic_on("Bulleted", 12.0, &arc, None);
|
||||
bullet.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||||
let l = assert_fills(&bullet, "bulleted");
|
||||
assert!(!l.markers.is_empty());
|
||||
let mut optical = dynamic_on("“Quoted.”", 12.0, &arc, None);
|
||||
optical.optical_margin = true;
|
||||
assert!(assert_fills(&optical, "optical margin").fit_scale > 1.0);
|
||||
let mut spaced = dynamic_on("Spaced around a curve", 12.0, &arc, None);
|
||||
spaced.path_spacing = 10.0;
|
||||
assert!(assert_fills(&spaced, "spacing").fit_scale > 1.0);
|
||||
}
|
||||
|
||||
/// The search lays the text out at most 11 times past the first, however far the size moves.
|
||||
#[test]
|
||||
fn dynamic_text_lays_out_a_bounded_number_of_times() {
|
||||
for (text, len) in [("Fill me", 400.0), (COPY, 400.0), (COPY, 0.01), ("i", 100_000.0), ("Round", 37.3)] {
|
||||
let before = crate::layout::fit_passes();
|
||||
let l = layout(db(), &dynamic(text, 12.0, len));
|
||||
let passes = crate::layout::fit_passes() - before;
|
||||
assert!((1..=11).contains(&passes), "{text:?} on {len}: {passes} passes (fit {})", l.fit_scale);
|
||||
}
|
||||
// A straight span with no spacing starts from the right size: a few passes.
|
||||
let before = crate::layout::fit_passes();
|
||||
layout(db(), &dynamic(COPY, 12.0, 300.0));
|
||||
assert!(crate::layout::fit_passes() - before <= 4);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,14 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
match kind {
|
||||
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
|
||||
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
|
||||
TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())),
|
||||
TextKind::OnPath { .. } => {
|
||||
items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none()));
|
||||
// Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread
|
||||
// (<https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html>).
|
||||
if crate::menus::enabled(app, "type.dynamicText") {
|
||||
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if is_image {
|
||||
// Offset Path and Simplify don't apply to an image.
|
||||
|
||||
@@ -2263,6 +2263,7 @@ font-technology Variable Променливи
|
||||
Open… Отваряне…
|
||||
Optical Оптично
|
||||
Optical Margin Alignment Подравняване по оптично поле
|
||||
Dynamic Text Динамичен текст
|
||||
Optimize for fast web view Оптимизиране за бърз уеб преглед
|
||||
Optimized Оптимизирано
|
||||
Options Опции
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -399,6 +399,7 @@
|
||||
Smart Punctuation… Pametna interpunkcija…
|
||||
Create Outlines Kreiraj obrise
|
||||
Optical Margin Alignment Optičko poravnanje margina
|
||||
Dynamic Text Dinamički tekst
|
||||
Fill with Placeholder Text Ispuni tekstom čuvara mjesta
|
||||
Insert Inline Symbol Umetni simbol u redu
|
||||
Show Hidden Characters Prikaži skrivene znakove
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2884,6 +2884,8 @@ digits Malayalam Malaiàlam
|
||||
Open… Obre…
|
||||
Optical Òptic
|
||||
Optical Margin Alignment Alineació òptica de marges
|
||||
Dynamic Text Text dinàmic
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames}
|
||||
Optimize for fast web view Optimitza per a la visualització web ràpida
|
||||
Optimized Optimitzat
|
||||
Options Opcions
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -406,6 +406,8 @@
|
||||
Smart Punctuation… Inteligentní interpunkce…
|
||||
Create Outlines Převést na křivky
|
||||
Optical Margin Alignment Optické zarovnání okrajů
|
||||
Dynamic Text Dynamický text
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames}
|
||||
Fill with Placeholder Text Vyplnit zástupným textem
|
||||
Insert Inline Symbol Vložit symbol do textu
|
||||
Show Hidden Characters Zobrazit skryté znaky
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -367,6 +367,7 @@
|
||||
Smart Punctuation… Smart tegnsætning…
|
||||
Create Outlines Opret konturer
|
||||
Optical Margin Alignment Optisk marginjustering
|
||||
Dynamic Text Dynamisk tekst
|
||||
Fill with Placeholder Text Udfyld med pladsholdertekst
|
||||
Insert Inline Symbol Indsæt indlejret symbol
|
||||
Show Hidden Characters Vis skjulte tegn
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2289,6 +2289,8 @@ file-filter Templates Vorlagen
|
||||
Open… Öffnen…
|
||||
Optical Optisch
|
||||
Optical Margin Alignment Optischer Randausgleich
|
||||
Dynamic Text Dynamischer Text
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames}
|
||||
Optimize for fast web view Für schnelle Webanzeige optimieren
|
||||
Optimized Optimiert
|
||||
Options Optionen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3051,6 +3051,8 @@ theme Appearance Εμφάνιση
|
||||
Open the URL in the web browser Άνοιγμα της διεύθυνσης στο πρόγραμμα περιήγησης
|
||||
Optical Οπτική
|
||||
Optical Margin Alignment Οπτική στοίχιση περιθωρίου
|
||||
Dynamic Text Δυναμικό κείμενο
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames}
|
||||
Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό
|
||||
Options for Selection… Επιλογές για την επιλογή…
|
||||
or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2335,6 +2335,8 @@ font-technology Variable Variables
|
||||
Open… Abrir…
|
||||
Optical Óptico
|
||||
Optical Margin Alignment Alineación óptica de márgenes
|
||||
Dynamic Text Texto dinámico
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames}
|
||||
Optimize for fast web view Optimizar para vista web rápida
|
||||
Optimized Optimizado
|
||||
Options Opciones
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -403,6 +403,8 @@
|
||||
Smart Punctuation… Älykäs välimerkitys…
|
||||
Create Outlines Muunna ääriviivoiksi
|
||||
Optical Margin Alignment Optinen reunustasaus
|
||||
Dynamic Text Dynaaminen teksti
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames}
|
||||
Fill with Placeholder Text Täytä paikkamerkkitekstillä
|
||||
Insert Inline Symbol Lisää tekstinsisäinen symboli
|
||||
Show Hidden Characters Näytä piilotetut merkit
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2327,6 +2327,8 @@ font-technology Variable Variables
|
||||
Open… Ouvrir…
|
||||
Optical Optique
|
||||
Optical Margin Alignment Alignement optique des marges
|
||||
Dynamic Text Texte dynamique
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames}
|
||||
Optimize for fast web view Optimiser pour un affichage web rapide
|
||||
Optimized Optimisé
|
||||
Options Options
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3137,6 +3137,8 @@ antiAlias Sharp शार्प
|
||||
OpenType Features ओपन टाइप सुविधाएँ
|
||||
Optical ऑप्टिकल
|
||||
Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण
|
||||
Dynamic Text डायनैमिक टेक्स्ट
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames}
|
||||
Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़
|
||||
Optimized ऑप्टिमाइज़्ड
|
||||
Options ऑप्शन्स
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2822,6 +2822,8 @@ file-filter Templates Sablonok
|
||||
Open… Megnyitás…
|
||||
Optical Optikai
|
||||
Optical Margin Alignment Optikai margóigazítás
|
||||
Dynamic Text Dinamikus szöveg
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames}
|
||||
Optimize for fast web view Optimalizálás gyors webes megtekintéshez
|
||||
Optimized Optimalizált
|
||||
Options Beállítások
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -367,6 +367,7 @@
|
||||
Smart Punctuation… Tanda baca cerdas…
|
||||
Create Outlines Buat garis luar
|
||||
Optical Margin Alignment Perataan margin optik
|
||||
Dynamic Text Teks dinamis
|
||||
Fill with Placeholder Text Isi dengan teks placeholder
|
||||
Insert Inline Symbol Sisipkan simbol sebaris
|
||||
Show Hidden Characters Tampilkan karakter tersembunyi
|
||||
|
||||
|
@@ -2365,6 +2365,8 @@ file-filter Templates Modelli
|
||||
Open… Apri…
|
||||
Optical Ottica
|
||||
Optical Margin Alignment Allineamento ottico dei margini
|
||||
Dynamic Text Testo dinamico
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames}
|
||||
Optimize for fast web view Ottimizza per visualizzazione web veloce
|
||||
Optimized Ottimizzato
|
||||
Options Opzioni
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -434,6 +434,8 @@ digits Malayalam マラヤーラム数字
|
||||
Smart Punctuation… 欧文用字形の自動置換…
|
||||
Create Outlines アウトラインを作成
|
||||
Optical Margin Alignment 最適なマージン揃え
|
||||
Dynamic Text ダイナミックテキスト
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames}
|
||||
Fill with Placeholder Text サンプルテキストの割り付け
|
||||
Insert Inline Symbol インラインシンボルを挿入
|
||||
Show Hidden Characters 制御文字を表示
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -332,6 +332,8 @@
|
||||
Smart Punctuation… ჭკვიანი პუნქტუაცია…
|
||||
Create Outlines კონტურებად გარდაქმნა
|
||||
Optical Margin Alignment ოპტიკური არშიის სწორება
|
||||
Dynamic Text დინამიკური ტექსტი
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames}
|
||||
Fill with Placeholder Text შემავსებელი ტექსტით შევსება
|
||||
Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა
|
||||
Show Hidden Characters დამალული სიმბოლოების ჩვენება
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -427,6 +427,8 @@ digits Malayalam 말라얄람어
|
||||
Smart Punctuation… 스마트 문장 부호…
|
||||
Create Outlines 윤곽선 만들기
|
||||
Optical Margin Alignment 시각적 여백 정렬
|
||||
Dynamic Text 동적 텍스트
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames}
|
||||
Fill with Placeholder Text 자리 표시 문자로 채우기
|
||||
Insert Inline Symbol 인라인 심볼 삽입
|
||||
Show Hidden Characters 숨겨진 문자 표시
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -381,6 +381,7 @@
|
||||
Smart Punctuation… Slimme interpunctie…
|
||||
Create Outlines Omzetten in curven
|
||||
Optical Margin Alignment Optische marge-uitlijning
|
||||
Dynamic Text Dynamische tekst
|
||||
Fill with Placeholder Text Vullen met plaatshoudertekst
|
||||
Insert Inline Symbol Inline-symbool invoegen
|
||||
Show Hidden Characters Verborgen tekens tonen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -365,6 +365,8 @@
|
||||
Smart Punctuation… Inteligentna interpunkcja…
|
||||
Create Outlines Utwórz kontury
|
||||
Optical Margin Alignment Optyczne wyrównanie marginesów
|
||||
Dynamic Text Tekst dynamiczny
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames}
|
||||
Fill with Placeholder Text Wypełnij tekstem zastępczym
|
||||
Insert Inline Symbol Wstaw symbol w tekście
|
||||
Show Hidden Characters Pokaż ukryte znaki
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1931,6 +1931,8 @@ antiAlias Sharp Nítido
|
||||
Open… Abrir…
|
||||
Optical Óptico
|
||||
Optical Margin Alignment Alinhamento de Margem Óptica
|
||||
Dynamic Text Texto Dinâmico
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames}
|
||||
Optimize for fast web view Otimizar para visualização rápida na web
|
||||
Optimized Otimizado
|
||||
Options Opções
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2360,6 +2360,8 @@ file-filter Templates Шаблоны
|
||||
Open… Открыть…
|
||||
Optical Оптический
|
||||
Optical Margin Alignment Оптическое выравнивание полей
|
||||
Dynamic Text Динамический текст
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames}
|
||||
Optimize for fast web view Оптимизировать для быстрого просмотра в web
|
||||
Optimized Оптимизировано
|
||||
Options Параметры
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2274,6 +2274,8 @@ font-technology Variable Variabla
|
||||
Open… Öppna…
|
||||
Optical Optisk
|
||||
Optical Margin Alignment Optisk marginaljustering
|
||||
Dynamic Text Dynamisk text
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames}
|
||||
Optimize for fast web view Optimera för snabb webbvisning
|
||||
Optimized Optimerad
|
||||
Options Alternativ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2146,6 +2146,8 @@ shape Open Açık
|
||||
Open… Aç…
|
||||
Optical Optik
|
||||
Optical Margin Alignment Optik Kenar Boşluğu Hizalaması
|
||||
Dynamic Text Dinamik Metin
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames}
|
||||
Optimize for fast web view Hızlı web görünümü için optimize edin
|
||||
Optimized Optimize edilmiş
|
||||
Options Seçenekler
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2357,6 +2357,8 @@ file-filter Templates Шаблони
|
||||
Open… Відкрити…
|
||||
Optical Оптичний
|
||||
Optical Margin Alignment Оптичне вирівнювання полів
|
||||
Dynamic Text Динамічний текст
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames}
|
||||
Optimize for fast web view Оптимізувати для швидкого перегляду web
|
||||
Optimized Оптимізовано
|
||||
Options Параметри
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -2635,6 +2635,8 @@ digits Hindi Hindi
|
||||
Open… Mở…
|
||||
Optical Quang học
|
||||
Optical Margin Alignment Căn lề quang học
|
||||
Dynamic Text Văn bản động
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames}
|
||||
Optimize for fast web view Tối ưu để xem web nhanh
|
||||
Optimized Đã tối ưu
|
||||
Options Tùy chọn
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -2233,6 +2233,8 @@ file-filter Templates 模板
|
||||
Open… 打开…
|
||||
Optical 视觉
|
||||
Optical Margin Alignment 视觉边界对齐
|
||||
Dynamic Text 动态文本
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames}
|
||||
Optimize for fast web view 优化快速 Web 查看
|
||||
Optimized 优化
|
||||
Options 选项
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3305,6 +3305,8 @@ file-filter Templates 範本
|
||||
Open… 開啟…
|
||||
Optical 視覺
|
||||
Optical Margin Alignment 視覺邊界對齊
|
||||
Dynamic Text 動態文字
|
||||
@msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames}
|
||||
Optimize for fast web view 最佳化以便快速網頁檢視
|
||||
Optimized 最佳化
|
||||
Options 選項
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -41,6 +41,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical")
|
||||
}
|
||||
"type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true),
|
||||
// Dynamic Text of the first selected type on a path, which the item turns the other way.
|
||||
"type.dynamicText" => selected_text(true).is_some_and(|t| t.dynamic_text),
|
||||
"view.outline" => app.outline_view(),
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.trimView" => v.trim_view,
|
||||
|
||||
@@ -313,6 +313,44 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() {
|
||||
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
|
||||
}
|
||||
|
||||
/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path that is in no
|
||||
/// thread, and is checked while the selected type on a path has it on.
|
||||
#[test]
|
||||
fn dynamic_text_is_a_checked_type_menu_item() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let ids: Vec<&str> = menu_tree()
|
||||
.into_iter()
|
||||
.filter(|(menu, _)| *menu == "Type")
|
||||
.flat_map(|(_, items)| items)
|
||||
.filter_map(|item| match item {
|
||||
Item::Cmd("Dynamic Text", id, _) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids, ["type.dynamicText"]);
|
||||
let id = "type.dynamicText";
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
assert!(!enabled(&app, id));
|
||||
let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [point]})).unwrap();
|
||||
assert!(!enabled(&app, id), "point type");
|
||||
let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone();
|
||||
let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
assert!(enabled(&app, id));
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
let frame = app.run("shape.rectangle", json!({"x": 60, "y": 200, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t, frame]})).unwrap();
|
||||
app.run("text.thread.create", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
assert!(!enabled(&app, id), "type on a path in a thread");
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked.
|
||||
#[test]
|
||||
fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() {
|
||||
|
||||
@@ -399,6 +399,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
.chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
|
||||
.collect(),
|
||||
),
|
||||
c("Dynamic Text", "type.dynamicText"),
|
||||
sub(
|
||||
"Threaded Text",
|
||||
vec![
|
||||
|
||||
@@ -184,6 +184,30 @@ fn type_on_a_path_offers_its_options() {
|
||||
assert_eq!(d.fields.get("effect"), Some(&json!("skew")));
|
||||
}
|
||||
|
||||
/// Type on a path offers Dynamic Text, which turns it on and off; type in a thread does not.
|
||||
#[test]
|
||||
fn type_on_a_path_offers_dynamic_text() {
|
||||
let mut app = app();
|
||||
let path = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap());
|
||||
let t = id(app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap());
|
||||
app.run("tool.select", json!({"tool": "selection"})).unwrap();
|
||||
select(&mut app, &[t]);
|
||||
let dynamic = |app: &VectorcraftApp| matches!(kind(app, t), NodeKind::Text(ref t) if t.dynamic_text);
|
||||
let (_, texts) = bar(&mut app);
|
||||
assert!(has(&texts, "Dynamic Text"), "{texts:?}");
|
||||
click(&mut app, "Dynamic Text");
|
||||
assert!(dynamic(&app));
|
||||
click(&mut app, "Dynamic Text");
|
||||
assert!(!dynamic(&app));
|
||||
// Threaded, it is not offered.
|
||||
let frame = id(app.run("shape.rectangle", json!({"x": 60, "y": 200, "width": 200, "height": 100})).unwrap());
|
||||
select(&mut app, &[t, frame]);
|
||||
app.run("text.thread.create", json!({})).unwrap();
|
||||
select(&mut app, &[t]);
|
||||
let (_, texts) = bar(&mut app);
|
||||
assert!(!has(&texts, "Dynamic Text"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// The path's anchors (its first subpath).
|
||||
fn anchors(app: &VectorcraftApp, id: u64) -> Vec<vectorcraft_geom::Anchor> {
|
||||
match kind(app, id) {
|
||||
|
||||
+28
-1
@@ -1389,7 +1389,7 @@ These commands refuse a string, a number or another value that is not true or fa
|
||||
`clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create`
|
||||
and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak`
|
||||
in `text.setRangeStyle` and `text.setFormat`, which also checks `hyphenate`, `hyphenateCapitalized` and
|
||||
`romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.systemLayout`
|
||||
`romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.dynamicText`, `type.systemLayout`
|
||||
and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in
|
||||
`symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in
|
||||
`swatch.delete`; `fromArtwork`, `toGlobal` and `includeTints` in `swatch.newGroup`; `global` and `selection` in
|
||||
@@ -7299,6 +7299,33 @@ begins or ends, dragging the centre bracket slides the type along its path and,
|
||||
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
|
||||
```
|
||||
|
||||
## Dynamic Text
|
||||
|
||||
`type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns
|
||||
Dynamic Text on or off for the selected type on a path; without `on` it sets the opposite of the first object's
|
||||
setting. With Dynamic Text on, the text spans the stretch between its start and end brackets and its size follows
|
||||
the text, growing or shrinking to fill it, and it fits again after each edit to the text and each move of a bracket
|
||||
([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html),
|
||||
[start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)).
|
||||
The rest of this entry is VectorCraft's own rules. The layout scales every character's size, leading, baseline shift
|
||||
and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on a Path Options › Spacing is not
|
||||
scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that
|
||||
still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored
|
||||
sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps
|
||||
its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a
|
||||
thread is refused with an error that names it, and the menu item and the task bar item are not offered for it;
|
||||
`text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and
|
||||
list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step
|
||||
is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked
|
||||
while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type
|
||||
on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the
|
||||
editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says
|
||||
so in a warning.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}}
|
||||
```
|
||||
|
||||
## Paragraph attributes per paragraph
|
||||
|
||||
Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation,
|
||||
|
||||
@@ -1157,6 +1157,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
|
||||
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
|
||||
Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`).
|
||||
Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`).
|
||||
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
|
||||
The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction.
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
|
||||
| Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); Direct Selection shows the anchors and direction handles of selected type on a path's path and drags both, the brackets keeping their fractions of the path and the text reflowing along it (#3980, #3565; `text.reshapePath`, `text.setPathHandle`), as the [Direct Selection page](https://helpx.adobe.com/illustrator/using/tool-techniques/direct-selection-tool.html) describes for a path |
|
||||
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); Direct Selection shows the anchors and direction handles of selected type on a path's path and drags both, the brackets keeping their fractions of the path and the text reflowing along it (#3980, #3565; `text.reshapePath`, `text.setPathHandle`), as the [Direct Selection page](https://helpx.adobe.com/illustrator/using/tool-techniques/direct-selection-tool.html) describes for a path; Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar) sizes type on a path to fill the span between its brackets and fits it again after each edit, as the [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes; the 0.1 pt steps, the 0.1 to 1,296 pt range and keeping the drawn sizes when it is turned off or threaded are VectorCraft's own rules (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`) |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
|
||||
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
|
||||
Reference in New Issue
Block a user