Merge pull request #1045 from lollinoo/codex/1034-text-scale
Fix font size and horizontal scale after text/group scaling
This commit is contained in:
commit
a7a4d952ea
|
|
@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
|
|
|
|||
|
|
@ -954,7 +954,49 @@ pub struct TextObject {
|
|||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
|
||||
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TextStyleScale {
|
||||
pub points: f64,
|
||||
pub horizontal: f64,
|
||||
}
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
/// be edited by compensating the stored attributes.
|
||||
pub fn style_scale(&self) -> Option<TextStyleScale> {
|
||||
let [a, b, c, d, e, f] = self.xf.as_coeffs();
|
||||
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (a.hypot(b), c.hypot(d));
|
||||
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
|
||||
let horizontal = sx / sy;
|
||||
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// Avoid a pure rotation turning otherwise identical fields into mixed values.
|
||||
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
|
||||
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
|
||||
}
|
||||
|
||||
/// Character attributes as presented by controls (stored runs stay local for layout
|
||||
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
|
||||
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
|
||||
let scale = self.style_scale()?;
|
||||
let mut style = style.clone();
|
||||
style.size *= scale.points;
|
||||
style.leading = style.leading.map(|v| v * scale.points);
|
||||
style.baseline_shift *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(style)
|
||||
}
|
||||
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
|
|
@ -1190,6 +1232,19 @@ impl TextObject {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
|
||||
t.xf = Affine::new(matrix);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none());
|
||||
}
|
||||
t.xf = Affine::rotate(0.83);
|
||||
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
|
||||
t.xf = Affine::scale(1e308);
|
||||
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_text_basics() {
|
||||
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1686,6 +1686,8 @@ mod tests_transparencygrid;
|
|||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_typescale;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_variables;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, size: f64) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn em(t: &TextObject, run: usize) -> (f64, f64) {
|
||||
let st = &t.runs[run].style;
|
||||
let origin = t.xf * Point::ZERO;
|
||||
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
|
||||
}
|
||||
|
||||
fn near(actual: f64, expected: f64) {
|
||||
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_setter_sets_document_points_and_undoes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150})).unwrap();
|
||||
let before = text(&s, id);
|
||||
near(em(&before, 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_font_size_setter_handles_different_accumulated_scales() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 48.0);
|
||||
near(em(&text(&s, b), 0).1, 36.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, a), 0).1, 10.0);
|
||||
near(em(&text(&s, b), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_size_and_keyboard_steps_use_document_points() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
near(em(&text(&s, id), 1).1, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 12.0);
|
||||
near(em(&after, 0).1, 12.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
|
||||
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
// The native document representation retains both the compensation and the affine.
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
|
||||
near(em(t, 0).0, 10.0);
|
||||
near(em(t, 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let query = |s: &mut Session, effective| -> Value {
|
||||
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
|
||||
};
|
||||
let effective = query(&mut s, true);
|
||||
assert_eq!(effective["size"], 24.0);
|
||||
assert_eq!(effective["h_scale"], 150.0);
|
||||
let raw = query(&mut s, false);
|
||||
assert_eq!(raw["size"], 12.0);
|
||||
assert_eq!(raw["h_scale"], 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(em(&t, 0).1, 0.1);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
|
||||
let mut s = session();
|
||||
let a = create(&mut s, 12.0);
|
||||
let b = create(&mut s, 18.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
|
||||
// Non-dimensional edits remain possible, including on imported collapsed text.
|
||||
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
|
||||
let after = text(&s, id);
|
||||
near(em(&after, 0).0, 10.0);
|
||||
near(em(&after, 0).1, 10.0);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 24.0);
|
||||
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
|
||||
near(em(&text(&s, id), 0).1, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.scale", &json!({"sx": 200})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
|
||||
s.prefs.type_size_increment = 2.0;
|
||||
s.prefs.baseline_shift_increment = 3.0;
|
||||
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
|
||||
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
|
||||
let t = text(&s, id);
|
||||
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
|
||||
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
|
||||
let mut s = session();
|
||||
let id = create(&mut s, 12.0);
|
||||
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
|
||||
let before = text(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
|
||||
assert_eq!(text(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
|
|
@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
|||
}
|
||||
|
||||
/// 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 size in steps of 0.1 pt (so its scaled size
|
||||
/// 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> {
|
||||
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
|
||||
// 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)?;
|
||||
if !(min.is_finite() && min < 1.0) {
|
||||
return None;
|
||||
}
|
||||
let tenths = size * 10.0;
|
||||
if !tenths.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
|
||||
// unless it is below the full size; `lo` is the smallest allowed.
|
||||
let mut lo = (tenths * min).ceil().max(1.0);
|
||||
|
|
@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
|
|||
}
|
||||
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
|
||||
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
|
||||
let mid = ((lo + hi) * 0.5).floor();
|
||||
let mid = (lo + (hi - lo) * 0.5).floor();
|
||||
let l = at(mid);
|
||||
passes += 1;
|
||||
if l.overflow {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
|
|||
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
|
||||
// A 14 pt story represented in a scaled local coordinate system. Its local size is
|
||||
// below 0.1, as happens when setting an exact size after enlarging a text group.
|
||||
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
|
||||
t.xf = Affine::scale(1000.0);
|
||||
assert!(at_size(&t, 0.014).overflow);
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
|
||||
let document_size = 14.0 * l.fit_scale;
|
||||
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[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));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
|||
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
|
||||
use vectorcraft_tools::{Mods, ToolKey};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, SpinPick, menu_item};
|
||||
|
|
@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0,
|
|||
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
|
||||
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
|
||||
|
||||
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
|
||||
/// tool edits text, else the first selected text object's first run. With it, the paragraph
|
||||
/// The stored character style: the selected range's (or the caret's) while the Type
|
||||
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
|
||||
/// attributes of the paragraph the selection starts in (else of the first paragraph).
|
||||
/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`.
|
||||
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
|
||||
if let Some((id, a, b)) = text_editing(app)
|
||||
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
|
||||
{
|
||||
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
|
||||
}
|
||||
let n = first_selected(app)?;
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
|
||||
_ => None,
|
||||
let t = first_selected_text(app)?;
|
||||
Some((t.first_style(), t.para_at(0).clone()))
|
||||
}
|
||||
|
||||
/// The first selected text descendant, shared by Character and Paragraph controls.
|
||||
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
|
||||
let st = app.session.active()?;
|
||||
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
let mut text = None;
|
||||
node.walk(&mut |n| {
|
||||
if text.is_none()
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
{
|
||||
text = Some(t.as_ref());
|
||||
}
|
||||
});
|
||||
if text.is_some() {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
|
||||
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
|
||||
/// blank field) where they differ, or with no type.
|
||||
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
|
||||
shared_styles(app, |_, style| Some(f(style)))
|
||||
}
|
||||
|
||||
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
|
||||
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
|
||||
/// even if a collapsed affine prevents editing dimensions.
|
||||
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
|
||||
shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
|
||||
}
|
||||
|
||||
struct Metric(f64);
|
||||
|
||||
impl PartialEq for Metric {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option<T>) -> Option<T> {
|
||||
let st = app.session.active()?;
|
||||
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t),
|
||||
|
|
@ -49,26 +85,26 @@ pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle)
|
|||
if let Some((id, a, b)) = text_editing(app) {
|
||||
let t = text(id)?;
|
||||
if b <= a {
|
||||
return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b)));
|
||||
return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
|
||||
}
|
||||
let mut end = 0;
|
||||
for r in &t.runs {
|
||||
let start = end;
|
||||
end += r.text.len();
|
||||
if start < b && end > a {
|
||||
acc.add(f(&r.style));
|
||||
acc.add(f(t, &r.style));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
t.runs.iter().for_each(|r| acc.add(f(&r.style)));
|
||||
t.runs.iter().for_each(|r| acc.add(f(t, &r.style)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
acc.value()
|
||||
acc.value().flatten()
|
||||
}
|
||||
|
||||
/// The value a run of styles share so far ([`shared`]).
|
||||
|
|
@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
|||
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
|
||||
cell(ui, "A↕", tl!("Leading"), |ui| {
|
||||
let leading = shared(app, CharStyle::effective_leading);
|
||||
let leading = shared_effective(app, CharStyle::effective_leading);
|
||||
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
|
||||
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
|
||||
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
|
||||
|
|
@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
|||
return;
|
||||
}
|
||||
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
format(app, json!({"vScale": v}));
|
||||
}
|
||||
});
|
||||
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
|
||||
format(app, json!({"hScale": v}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
|
||||
if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
|
||||
if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
|
||||
format(app, json!({"baselineShift": v}));
|
||||
}
|
||||
});
|
||||
|
|
@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
|
|||
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
|
||||
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
|
||||
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
|
||||
let size = shared(app, |s| s.size);
|
||||
let size = shared_effective(app, |s| s.size);
|
||||
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
|
||||
style(app, json!({"size": v}));
|
||||
}
|
||||
|
|
@ -646,6 +682,81 @@ mod tests {
|
|||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
|
||||
app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap();
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap();
|
||||
app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap();
|
||||
for (actual, expected) in [
|
||||
(shared_effective(&app, |s| s.size), 7.0),
|
||||
(shared_effective(&app, CharStyle::effective_leading), 9.0),
|
||||
(shared_effective(&app, |s| s.h_scale), 130.0),
|
||||
(shared_effective(&app, |s| s.baseline_shift), 3.0),
|
||||
] {
|
||||
assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_collapsed_text_keeps_nondimensional_controls_available() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap();
|
||||
app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
assert!(text_style(&app).is_some());
|
||||
assert_eq!(shared(&app, |s| s.tracking), Some(0.0));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), None);
|
||||
app.run("text.setStyle", json!({"tracking": 40})).unwrap();
|
||||
app.run("text.setFormat", json!({"leftIndent": 4})).unwrap();
|
||||
assert_eq!(shared(&app, |s| s.tracking), Some(40.0));
|
||||
assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0);
|
||||
let ctx = egui::Context::default();
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
|
||||
assert!(!texts.iter().any(|(s, _)| s == "No text selected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_character_fields_show_document_sizes_and_mixed_group_values() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
|
||||
app.run("object.scale", json!({"sx": 200})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0));
|
||||
assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes");
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size");
|
||||
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0));
|
||||
app.run("text.setStyle", json!({"size": 10})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
|
||||
app.run("text.setFormat", json!({"hScale": 100})).unwrap();
|
||||
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_range_fields_follow_the_type_tool_selection() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone();
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
assert!(text_editing(&app).is_some());
|
||||
app.run("object.scale", json!({"sx": 200})).unwrap();
|
||||
app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap();
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), None);
|
||||
app.session.set_tool_option("select", &json!({"start": 0, "end": 4}));
|
||||
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
|
||||
assert_eq!(text_style(&app).unwrap().0.size, 5.0);
|
||||
}
|
||||
|
||||
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
|
||||
#[test]
|
||||
fn bold_and_italic_pick_the_familys_own_faces() {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
|
|||
/// right to left: its Paragraph Direction, else from its first strong character?
|
||||
fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool {
|
||||
let editing = super::character::text_editing(app);
|
||||
let selected = super::first_selected(app);
|
||||
let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref());
|
||||
let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) };
|
||||
let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.as_ref()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) };
|
||||
let plain = t.plain_text();
|
||||
let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a));
|
||||
vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction)
|
||||
|
|
@ -246,6 +248,8 @@ mod tests {
|
|||
shown_alignment(para.justify, resolved_rtl(app, ¶))
|
||||
};
|
||||
assert_eq!(shown(&app), Justify::Right);
|
||||
app.session.execute("object.group", &json!({})).unwrap();
|
||||
assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction");
|
||||
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
|
||||
assert_eq!(shown(&app), Justify::Left);
|
||||
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() {
|
|||
assert_eq!(undo_len(&app), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_font_size_fields_and_preset_use_document_points() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let (mut app, id) = app_with_text();
|
||||
run(&mut app, "object.scale", json!({"sx": 200}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
|
||||
let at = Cell::new(egui::Pos2::ZERO);
|
||||
frame_events(&ctx, &mut app, vec![], size_combo(&at));
|
||||
click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at));
|
||||
let t = frame_events(&ctx, &mut app, vec![], size_combo(&at));
|
||||
let undo = undo_len(&app);
|
||||
click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at));
|
||||
assert_eq!(style(&app, id).size * 2.0, 10.0);
|
||||
assert_eq!(undo_len(&app), undo + 1);
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_group_shows_character_and_control_bar_size_fields() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let (mut app, _) = app_with_text();
|
||||
run(&mut app, "object.group", json!({}));
|
||||
run(&mut app, "object.scale", json!({"sx": 200}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
|
||||
run(&mut app, "text.setStyle", json!({"size": 10}));
|
||||
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_presets_show_in_the_type_unit() {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
|
||||
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
|
||||
|
|
@ -86,6 +86,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
|||
|
||||
## G11 Correctness bugs in core tools
|
||||
|
||||
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
|
||||
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
|
||||
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
|
||||
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
|
||||
- **Impact:** wrong output in everyday work.
|
||||
|
|
@ -96,7 +98,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
|||
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
|
||||
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
|
||||
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md).
|
||||
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
|
||||
Itemized in [type-parity.md](type-parity.md).
|
||||
- **Impact:** professional typography and Japanese/Chinese publishing.
|
||||
- **Estimate:** 34–47 h (type core 14–19 h blocks beta).
|
||||
|
||||
|
|
@ -200,6 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |
|
||||
| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) |
|
||||
| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) |
|
||||
| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
|
||||
|
|
@ -54,7 +54,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
|||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
|
|
@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
|
||||
| 2026-10-10 | minor | M9 row from main: initial Revolve |
|
||||
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list |
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
|||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
|
|
@ -23,6 +23,8 @@ against Illustrator's public documentation; **estimated** unless noted.
|
|||
| Threaded text, Fit Headline, Text Wrap | done | |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
|
||||
| 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. |
|
||||
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
|
||||
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
|
||||
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
|
||||
| Variable fonts | partial | named instances as styles; axis sliders missing |
|
||||
|
|
@ -48,4 +50,5 @@ against Illustrator's public documentation; **estimated** unless noted.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
|
||||
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
|
||||
|
|
|
|||
Loading…
Reference in New Issue