#1034: scaled type shows and takes its scaled sizes

After scaling 12 pt type 2x, the Character panel still showed 12 pt and
typing 10 pt gave 20 pt type. Character controls and text commands now
work in document points: the size, leading and baseline shift shown and
set are the stored ones times the text's scale along its em height, and
horizontal scale includes the ratio of the transformed em axes. Rotation,
reflection and shear stay in the transform, so text remains editable.
Groups' text counts, keyboard increments use the same units, Shrink
Text to Fit works in document tenths, and a collapsed transform rejects
dimensional edits without touching the document.

text.getRange takes effective: true for the document-space values.
This commit is contained in:
lollinoo
2026-10-11 07:00:34 +07:00
committed by bflatastic
parent 611f6855ef
commit c5274beb23
17 changed files with 568 additions and 62 deletions
+8 -13
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let ids = super::typecmd::text_targets(s, p, C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
@@ -285,15 +278,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
if let Some(v) = baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
if let Some(v) = h_scale {
st.h_scale = v;
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
+44 -15
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Get Text Range",
[],
None,
"{id, start?, end?} → {text, runs: [{text, style}], length}",
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
has_doc,
get_range
),
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let runs = edit::slice_runs(&t.runs, a, b);
let mut runs = edit::slice_runs(&t.runs, a, b);
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
@@ -316,6 +321,15 @@ impl CharChange {
&& self.char_align.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
use super::typecmd::local_type_value;
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
Ok(())
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
if let Some(f) = &self.font {
st.font_family = f.clone();
@@ -338,10 +352,10 @@ impl CharChange {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(v) = self.leading {
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
st.leading = v;
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
@@ -350,10 +364,10 @@ impl CharChange {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
st.baseline_shift = v;
}
if let Some(v) = self.h_scale {
st.h_scale = v.clamp(1.0, 10000.0);
st.h_scale = v;
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -445,12 +459,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
@@ -605,13 +620,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for &(id, a, b) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| match step {
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
});
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
} else {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
for run in &mut runs {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
}
}
edit::replace_range_styled(&mut t.runs, a, b, &runs);
refresh_bounds(t);
}
Ok(())
+20 -2
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
/// the local result would make small sizes impossible after enlarging an object.
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
if !value.is_finite() {
return Err(bad(cmd, "type dimension must be finite"));
}
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
// Bound local dimensions too: an extreme inverse scale can produce a finite size
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
}
Ok(local)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
let before = protect.then(|| t.runs.clone());
style_chars(t, range, |st| {
if let Some(f) = &font {
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(l) = leading {
st.leading = l;