Object Mosaic: columns and rows that are not whole numbers are refused

object.createObjectMosaic read columns and rows with as_u64 and fell
back to 10 for anything else, so 2.5, -3 or "4" made a 10 x 10 mosaic
and reported success. Transform Each read reference and seed the same
way (a reference of 2.5 used the center point, and a seed of 7.5 used
the default seed), and Rearrange All Artboards cut 1.5 columns to 1
and read "1" as the default 2.

These five parameters now take a whole number, written 4 or 4.0 as the
Rearrange All Artboards dialog sends it. A fraction or a negative
number is refused with "`columns` must be a whole number", the message
object.slice.divide gives. A string or another value that is not a
number is refused with the check_numbers error. The command then
changes nothing. The existing ranges stay: 1 to 1000 mosaic tiles
across and down, a reference of at most 8, and 1 to the artboard
count of columns.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 16:38:42 +07:00
committed by bflatastic
parent 3adf5141e7
commit d18135bfcd
5 changed files with 96 additions and 7 deletions
+21 -7
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Object Mosaic…",
["Object"],
None,
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
has_image,
object_mosaic
),
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
/// "`{key}` must be a whole number".
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
check_numbers(p, &[key], cmd)?;
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
match (v.as_u64(), v.as_f64()) {
(Some(n), _) => Ok(n),
// A whole number past `u64::MAX` saturates to it.
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// Run a command's implementation without the enablement check or journaling.
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
@@ -335,13 +349,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
}
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
// With no object to transform, or without a copy when both scales are 100% and there is no
@@ -524,8 +538,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
@@ -916,8 +930,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
let count = s.doc()?.doc.artboards.len();
let n_ab = count.max(1);
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
// Columns of a grid by row, rows of a grid by column.
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
let (rows, cols) = match layout {
L::GridByRow => (n_ab.div_ceil(lines), lines),
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
+23
View File
@@ -2308,6 +2308,29 @@ fn object_mosaic_tiles_follow_the_image_colours() {
assert!(!st.doc.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
}
/// Columns and rows that are not whole numbers (a fraction, a negative number, a string or another value) are an
/// error naming the key, and no mosaic is made; 2.0 columns are two columns.
#[test]
fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = undo(&s);
for (p, msg) in [
(json!({"columns": 2.5}), "`columns` must be a whole number"),
(json!({"rows": -1}), "`rows` must be a whole number"),
(json!({"columns": "4"}), "`columns` must be a number, not \"4\""),
(json!({"rows": true}), "`rows` must be a number, not true"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!(undo(&s), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2.0, "rows": 1.0})).unwrap();
assert_eq!(r["tiles"], json!(2));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+45
View File
@@ -357,6 +357,30 @@ fn transform_each_random_is_deterministic_with_seed() {
assert!(x.x0.abs() <= 50.0 && x.width() == 100.0);
}
/// A `reference` or `seed` that is not a whole number (a fraction, a negative number or a string) is an error naming
/// the key, and the object stays as it was; a `reference` of 0.0 is the top-left point.
#[test]
fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let before = (undo_len(&s), bounds(&s, a));
for (p, msg) in [
(json!({"scaleH": 50, "reference": 2.5}), "`reference` must be a whole number"),
(json!({"scaleH": 50, "reference": -1}), "`reference` must be a whole number"),
(json!({"scaleH": 50, "reference": "0"}), "`reference` must be a number, not \"0\""),
(json!({"moveH": 50, "random": true, "seed": 7.5}), "`seed` must be a whole number"),
(json!({"moveH": 50, "random": true, "seed": -7}), "`seed` must be a whole number"),
(json!({"moveH": 50, "random": true, "seed": "7"}), "`seed` must be a number, not \"7\""),
] {
let r = s.execute("object.transformEach", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
}
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "reference": 0.0})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
}
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
#[test]
@@ -705,6 +729,27 @@ fn convert_to_artboards_and_rearrange() {
assert_eq!(bounds(&s, b), Rect::new(50.0, 675.0, 60.0, 685.0));
}
/// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string),
/// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column.
#[test]
fn rearrange_refuses_columns_that_are_not_a_whole_number() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let before = (boards(&s), undo_len(&s));
for (p, msg) in [
(json!({"columns": 1.5}), "`columns` must be a whole number"),
(json!({"columns": -1}), "`columns` must be a whole number"),
(json!({"columns": "1"}), "`columns` must be a number, not \"1\""),
] {
let r = s.execute("artboard.rearrange", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
}
let r = s.execute("artboard.rearrange", &json!({"columns": 1.0, "spacing": 50})).unwrap();
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
+6
View File
@@ -844,6 +844,12 @@ of the format they write (one object can carry several formats' options). A comm
(`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or
journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read.
`object.createObjectMosaic` (`columns`, `rows`), `object.transformEach` (`reference`, `seed`) and `artboard.rearrange`
(`columns`) take whole numbers, written `4` or `4.0`. A fraction or a negative number is refused with
`` `columns` must be a whole number ``. A string or another value that is not a number is refused with
`` `columns` must be a number, not "4" ``. Each message names the key that was given, and the command then changes
nothing.
A step can use an earlier step's result, in `run` as in the `command_batch` tool. A parameter string that is exactly
`$N` or `$N.path` takes that value from the result of step `N` (counting from 0, in the order they ran; `run --in`'s
open isn't a step), and `$last…` from the step before. A path goes into objects by key and arrays by index:
+1
View File
@@ -636,6 +636,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s.
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4.
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).