Merge wp/WP-00: neutral labels, packaging metadata and vendor-name CI gate (M3.7, M4.10, M4.11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 11:57:34 +07:00
commit 115255a7e4
31 changed files with 744 additions and 139 deletions

View File

@ -125,7 +125,7 @@ impl IccProfile {
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
let p = match name {
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(),
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
super::SRGB => ColorProfile::new_srgb(),

View File

@ -1,8 +1,8 @@
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
//! gamut checks and soft-proof transforms.
//!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
@ -25,7 +25,8 @@ pub use lab::{Lab, delta_e76, delta_e2000};
use crate::Color;
pub const SRGB: &str = "sRGB IEC61966-2.1";
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
/// The wide-gamut RGB space with the 1998 primaries (γ 2.2, D65).
pub const WIDE_GAMUT_RGB: &str = "Wide Gamut RGB (1998 primaries)";
pub const DISPLAY_P3: &str = "Display P3";
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
@ -33,6 +34,14 @@ pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)";
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
/// Built-in RGB and CMYK working spaces, in menu order.
const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB];
const BUILTIN_CMYK: [&str; 2] = [GENERIC_CMYK, DEVICE_CMYK];
/// Names earlier versions gave built-in profiles: (old name, current name). Settings, documents
/// and commands that still use an old name get the current profile.
pub const LEGACY_NAMES: &[(&str, &str)] = &[("Adobe RGB (1998) compatible", WIDE_GAMUT_RGB)]; // brand-ok: legacy alias
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
pub const GAMUT_THRESHOLD: f32 = 2.0;
@ -169,7 +178,7 @@ fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
}
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
match name {
match canonical_name(name) {
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
DEVICE_CMYK => Ok(CmykSpace::Device),
_ => match user_profile(name) {
@ -194,12 +203,23 @@ fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
}
}
/// The current name of a profile: names earlier versions used resolve to today's name.
pub fn canonical_name(name: &str) -> &str {
LEGACY_NAMES.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new)
}
/// An available profile by its current or legacy name.
pub fn profile(name: &str) -> Option<ProfileInfo> {
let name = canonical_name(name);
profiles().into_iter().find(|p| p.name == name)
}
/// Every profile available (built-in first, then user-loaded).
pub fn profiles() -> Vec<ProfileInfo> {
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
let mut v: Vec<ProfileInfo> = BUILTIN_RGB
.iter()
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.chain(BUILTIN_CMYK.iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
@ -214,7 +234,8 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
if p.kind == ProfileKind::Gray {
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
}
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
let builtin = canonical_name(&p.name);
if BUILTIN_RGB.contains(&builtin) || BUILTIN_CMYK.contains(&builtin) {
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
}
if !p.usable() {
@ -257,8 +278,10 @@ impl Default for Cms {
}
impl Cms {
/// Profiles named by a legacy name are stored under their current name.
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() };
Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings })
}
pub fn settings(&self) -> &ColorSettings {
&self.settings

View File

@ -138,11 +138,11 @@ fn convert_between_models() {
#[test]
fn wide_gamut_working_space() {
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
let c = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
assert!(!c.rgb_is_srgb());
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
// Wide-gamut green is outside sRGB: it clips to (nearly) full sRGB green.
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
@ -150,6 +150,21 @@ fn wide_gamut_working_space() {
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
}
#[test]
fn legacy_profile_name_resolves() {
let (old, _) = LEGACY_NAMES[0];
assert_eq!(canonical_name(old), WIDE_GAMUT_RGB);
assert_eq!(canonical_name(SRGB), SRGB);
assert_eq!(profile(old).map(|p| (p.name, p.kind, p.builtin)), Some((WIDE_GAMUT_RGB.to_string(), ProfileKind::Rgb, true)));
assert!(profile("Nope").is_none());
assert!(profiles().iter().all(|p| p.name != old), "only the current name is listed");
// Settings saved with the old name load as the current profile, under its current name.
let c = Cms::new(&ColorSettings { rgb: old.into(), ..Default::default() }).unwrap();
assert_eq!(c.settings().rgb, WIDE_GAMUT_RGB);
let same = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
assert_eq!(c.rgb_to_srgb([0.0, 1.0, 0.0]), same.rgb_to_srgb([0.0, 1.0, 0.0]));
}
#[test]
fn proof_lut_matches_direct_proof() {
let c = cms();

View File

@ -157,10 +157,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
let mut st = cms::active_settings();
let before = st.clone();
if let Some(v) = str_param(p, "rgb") {
st.rgb = v.to_string();
st.rgb = cms::canonical_name(v).to_string();
}
if let Some(v) = str_param(p, "cmyk") {
st.cmyk = v.to_string();
st.cmyk = cms::canonical_name(v).to_string();
}
if let Some(i) = intent_param(p, C)? {
st.intent = i;
@ -189,24 +189,23 @@ fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
/// The document's assigned profiles (`None` = working space).
pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) {
let o = d.unknown.get(PROFILES_KEY);
let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(str::to_string);
let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(|n| cms::canonical_name(n).to_string());
(get("rgb"), get("cmyk"))
}
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.assignProfile";
let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc);
let known = cms::profiles();
let pick = |key: &str, cur: &mut Option<String>, kind: cms::ProfileKind| -> Result<bool> {
match p.get(key) {
None => Ok(false),
Some(Value::Null) => Ok(cur.take().is_some()),
Some(Value::String(n)) => {
if !known.iter().any(|k| &k.name == n && k.kind == kind) {
let Some(info) = cms::profile(n).filter(|k| k.kind == kind) else {
return Err(bad(C, format!("unknown {key} profile `{n}`")));
}
let changed = cur.as_deref() != Some(n.as_str());
*cur = Some(n.clone());
};
let changed = cur.as_deref() != Some(info.name.as_str());
*cur = Some(info.name);
Ok(changed)
}
Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))),
@ -363,7 +362,7 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(t) = str_param(p, "target") {
let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?;
if let ProofTarget::Cmyk(name) = &t
&& !cms::profiles().iter().any(|k| &k.name == name && k.kind == cms::ProfileKind::Cmyk)
&& !cms::profile(name).is_some_and(|k| k.kind == cms::ProfileKind::Cmyk)
{
return Err(bad(C, format!("unknown CMYK profile `{name}`")));
}

View File

@ -269,12 +269,12 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// Clipboard Handling
p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool),
p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "Legacy vector clipboard (no transparency)", bool),
p!(
"aicbMode",
"Clipboard Handling",
"On Copy",
"AICB",
"Legacy vector clipboard",
choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])
),
p!(

View File

@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped, like Illustrator) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),

View File

@ -13,7 +13,7 @@ use vectorcraft_render::{RenderOptions, Renderer};
use super::*;
/// Tests that touch the process-wide proof view or colour settings run one at a time.
/// Every test here reads or changes the process-wide proof view or colour settings: one at a time.
static GLOBAL: Mutex<()> = Mutex::new(());
fn session() -> Session {
@ -45,6 +45,7 @@ fn render(doc: &Document, opts: RenderOptions) -> vectorcraft_render::Rendered {
#[test]
fn document_mode_converts_through_cms() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000"));
let r = s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap();
@ -63,6 +64,7 @@ fn document_mode_converts_through_cms() {
#[test]
fn document_mode_conversion_is_idempotent() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#336699"));
rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!({"gray": 40}));
@ -79,6 +81,7 @@ fn document_mode_conversion_is_idempotent() {
#[test]
fn rgb_cmyk_rgb_document_roundtrip_within_tolerance() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
let cols = ["#cc9980", "#808080", "#4d7359", "#e6d94d"];
let ids: Vec<NodeId> = cols.iter().enumerate().map(|(i, h)| rect(&mut s, i as f64 * 20.0, 0.0, 10.0, 10.0, json!(h))).collect();
@ -95,6 +98,7 @@ fn rgb_cmyk_rgb_document_roundtrip_within_tolerance() {
#[test]
fn swatch_links_survive_mode_conversion() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#2a6fb0", "global": true})).unwrap();
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
@ -109,6 +113,7 @@ fn swatch_links_survive_mode_conversion() {
#[test]
fn gamut_check_flags_unprintable_colours() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#0000ff"));
rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!("#cc9980"));
@ -124,6 +129,7 @@ fn gamut_check_flags_unprintable_colours() {
#[test]
fn convert_query_and_intents() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
let q = |s: &mut Session, intent: &str| s.execute("color.convert", &json!({"color": "#0000ff", "to": "cmyk", "intent": intent})).unwrap();
let p = q(&mut s, "perceptual");
@ -170,6 +176,7 @@ fn color_settings_lists_profiles_and_validates() {
#[test]
fn proof_colors_simulate_press_on_screen() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#0000ff"));
let doc = s.doc().unwrap().doc.clone();
@ -186,6 +193,7 @@ fn proof_colors_simulate_press_on_screen() {
#[test]
fn separation_plates_render_ink_coverage() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 50.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0}));
rect(&mut s, 50.0, 0.0, 50.0, 100.0, json!({"c": 0, "m": 50, "y": 0, "k": 0}));
@ -223,6 +231,7 @@ fn separation_plates_render_ink_coverage() {
#[test]
fn rgb_art_separates_with_gcr() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000"));
let doc = s.doc().unwrap().doc.clone();
@ -274,6 +283,7 @@ fn spot_colours_get_their_own_plate() {
#[test]
fn overprint_black_multiplies_in_preview() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0}));
let k = rect(&mut s, 25.0, 25.0, 50.0, 50.0, json!({"c": 0, "m": 0, "y": 0, "k": 100}));
@ -304,6 +314,8 @@ fn view_toggles_redraw_without_dirtying() {
assert_eq!(r["target"], "deuteranopia");
assert_eq!(vectorcraft_render::proof::active_proof().unwrap().target, ProofTarget::Deuteranopia);
assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err());
assert!(s.execute("view.proofSetup", &json!({"target": format!("cmyk:{}", cms::WIDE_GAMUT_RGB)})).is_err(), "an RGB profile");
s.execute("view.proofSetup", &json!({"target": format!("cmyk:{}", cms::DEVICE_CMYK)})).unwrap();
s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap();
let r = s.execute("view.proofColors", &json!({"on": false})).unwrap();
assert_eq!(r["proofColors"], false);
@ -325,6 +337,7 @@ fn pdf_text(doc: &Document) -> String {
#[test]
fn pdf_cmyk_document_writes_device_cmyk() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000"));
let rgb_pdf = pdf_text(&s.doc().unwrap().doc);
@ -356,6 +369,7 @@ fn pdf_spot_colour_is_a_separation() {
#[test]
fn intents_reach_the_document_conversion() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut a = session();
rect(&mut a, 0.0, 0.0, 10.0, 10.0, json!("#00ff00"));
let mut b = session();
@ -377,3 +391,24 @@ fn intents_reach_the_document_conversion() {
assert_ne!(first(&a), first(&b));
let _ = Intent::Perceptual;
}
#[test]
fn legacy_profile_name_resolves_in_commands_and_files() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let (old, _) = cms::LEGACY_NAMES[0];
let mut s = session();
let before = cms::active_settings();
let r = s.execute("edit.colorSettings", &json!({"rgb": old})).unwrap();
assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB);
assert_eq!(cms::active_settings().rgb, cms::WIDE_GAMUT_RGB);
let names: Vec<&str> = r["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert!(names.contains(&cms::WIDE_GAMUT_RGB) && !names.contains(&old));
let r = s.execute("edit.assignProfile", &json!({"rgb": old})).unwrap();
assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB, "stored under the current name");
// A document saved by an older version names the profile the old way.
let mut d = (*s.doc().unwrap().doc).clone();
d.unknown.insert(cmd::colormgmt::PROFILES_KEY.into(), json!({"rgb": old, "cmyk": null}));
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert_eq!(cmd::colormgmt::doc_profiles(&back), (Some(cms::WIDE_GAMUT_RGB.to_string()), None));
cms::set_active(&before).unwrap();
}

View File

@ -18,7 +18,7 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a vector illustration app (an Illustrator clone). Coordinates are points in \
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \
document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \
draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \
find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \

View File

@ -302,7 +302,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj(
json!({
"effect": {"type": "string", "description": "Effect id (see the catalogue)"},
"params": {"type": "object", "description": "Effect parameters; missing keys take Illustrator's dialog defaults"},
"params": {"type": "object", "description": "Effect parameters; missing keys take the dialog defaults"},
"ids": {"type": "array", "items": {"type": "integer"}},
}),
&[],
@ -347,7 +347,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"create_graph",
"Create graph",
"Create a graph (Illustrator graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
obj(
json!({
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},

View File

@ -21,13 +21,13 @@ use vectorcraft_doc::Document;
/// PDF standard / version to target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compatibility {
/// Acrobat 5 (PDF 1.4).
/// PDF 1.4.
Pdf14,
/// Acrobat 6 (PDF 1.5).
/// PDF 1.5.
Pdf15,
/// Acrobat 7 (PDF 1.6).
/// PDF 1.6.
Pdf16,
/// Acrobat 8+ (PDF 1.7). The default.
/// PDF 1.7. The default.
#[default]
Pdf17,
/// PDF 2.0.

View File

@ -3,7 +3,7 @@ name = "vectorcraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft egui frontend (Illustrator-style UI); swappable — nothing below depends on it"
description = "VectorCraft egui frontend (menus, panels, tools and canvas); swappable — nothing below depends on it"
[lints]
workspace = true

View File

@ -31,6 +31,9 @@ pub mod unsaved;
pub mod widgets;
pub mod workspaces;
#[cfg(test)]
mod tests_labels;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};

View File

@ -20,7 +20,7 @@ pub enum Item {
Todo(&'static str, &'static str),
Sub(&'static str, Vec<Item>),
Sep,
/// Section header (disabled label, e.g. "Illustrator Effects").
/// Section header (disabled label, e.g. "Vector Effects").
Header(&'static str),
}
@ -39,6 +39,10 @@ fn todos(label: &'static str, sc: &'static str) -> Item {
fn sub(label: &'static str, items: Vec<Item>) -> Item {
Item::Sub(label, items)
}
/// Window → … Libraries until the code-generated libraries land (disabled entries).
fn library_placeholders() -> Vec<Item> {
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
}
use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
@ -94,7 +98,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"),
("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"),
("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"),
("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \"VectorCraft Defaults\" | \"Illustrator Defaults\"}"),
(
"shortcuts.preset",
"Keyboard Shortcut Set",
"",
"{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)",
),
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
@ -746,7 +755,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
todo("Transparency Flattener Presets…"),
todo("Print Presets…"),
todo("Adobe PDF Presets…"),
todo("PDF Presets…"),
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
Sep,
c("Color Settings…", "edit.colorSettings"),
@ -1218,10 +1227,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
),
todo("Variables"),
Sep,
sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]),
sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]),
sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]),
sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]),
sub("Brush Libraries", library_placeholders()),
sub("Graphic Style Libraries", library_placeholders()),
sub("Swatch Libraries", library_placeholders()),
sub("Symbol Libraries", library_placeholders()),
],
),
(
@ -1576,7 +1585,7 @@ fn font_items() -> Vec<Item> {
.collect()
}
/// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects.
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
fn effect_menu() -> Vec<Item> {
let cat = vectorcraft_effects::effect_catalog();
let mut out = vec![
@ -1585,7 +1594,7 @@ fn effect_menu() -> Vec<Item> {
Sep,
c("Document Raster Effects Settings…", "document.rasterEffectsSettings"),
Sep,
Item::Header("Illustrator Effects"),
Item::Header("Vector Effects"),
];
// Submenus in Illustrator's order.
let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"];

View File

@ -157,9 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Stroke:");
for (icon, tip) in [
("dc-stroke-center", "Apply gradient within stroke"),
("dc-stroke-outside", "Apply gradient along stroke (on the roadmap)"),
("dc-stroke-inside", "Apply gradient across stroke (on the roadmap)"),
("dc-stroke-center", "Gradient inside stroke"),
("dc-stroke-outside", "Gradient along stroke (on the roadmap)"),
("dc-stroke-inside", "Gradient across stroke (on the roadmap)"),
] {
widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
}

View File

@ -14,8 +14,7 @@ pub const WEIGHT_PRESETS: [f64; 22] =
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
/// Width profile presets: (id for `stroke.set`, label).
pub const PROFILES: [(&str, &str); 4] =
[("uniform", "Uniform"), ("lens", "Width Profile 1"), ("taperStart", "Width Profile 4"), ("taperEnd", "Width Profile 5")];
pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")];
/// Which preset a stroke's profile is (None profile = uniform).
pub fn profile_id(p: Option<&WidthProfile>) -> &'static str {
@ -165,19 +164,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space((ui.available_width() - 56.0).max(0.0));
let on = dash.is_some();
if widgets::icon_button_enabled(ui, "dc-dash-exact", "Preserves exact dash and gap lengths", on && !align_corners, on, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false}));
}
if widgets::icon_button_enabled(
ui,
"dc-dash-align",
"Aligns dashes to corners and path ends, adjusting lengths to fit",
on && align_corners,
on,
24.0,
)
.clicked()
{
if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() {
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true}));
}
});
@ -242,8 +232,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
ui.horizontal(|ui| {
row_label(ui, "Align:");
widgets::icon_button_enabled(ui, "dc-cap-square", "Extend arrow tip beyond end of path (on the roadmap)", true, false, 22.0);
widgets::icon_button_enabled(ui, "dc-cap-butt", "Place arrow tip at end of path (on the roadmap)", false, false, 22.0);
widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0);
widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0);
});
widgets::divider(ui);
// Profile.

View File

@ -431,7 +431,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
menu_item(ui, "Spot Colors…", false, false);
ui.separator();
menu_item(ui, "Open Swatch Library", false, false);
menu_item(ui, "Export Swatches Library as ASE…", false, false);
menu_item(ui, "Save Swatch Library…", false, false);
}
#[cfg(test)]

View File

@ -17,8 +17,20 @@ use crate::state::{Dialog, UiState};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, menus, widgets};
pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Illustrator Defaults"];
pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Classic Defaults"];
pub const CUSTOM: &str = "Custom";
/// Set names earlier versions saved: (old name, current name).
pub const LEGACY_PRESETS: &[(&str, &str)] = &[("Illustrator Defaults", "Classic Defaults")]; // brand-ok: legacy preference value
/// The current name of a shortcut set: names earlier versions saved map to today's.
pub fn set_name(name: &str) -> &str {
LEGACY_PRESETS.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new)
}
/// Serde reader for a saved set name (UI preferences written by earlier versions).
pub fn deserialize_set_name<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
<String as serde::Deserialize>::deserialize(d).map(|s| set_name(&s).to_string())
}
/// Bumped whenever overrides or the workspace list change (the native menu rebuilds on it).
pub static GENERATION: AtomicU64 = AtomicU64::new(0);
@ -281,7 +293,7 @@ pub fn reset_one(overrides: &mut BTreeMap<String, String>, key: &str) {
/// Overrides of a preset (both presets are the registry defaults for now).
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
PRESETS.contains(&name).then(BTreeMap::new)
PRESETS.contains(&set_name(name)).then(BTreeMap::new)
}
/// Export format: `{"format": "vectorcraft-shortcuts", "set": name, "overrides": {...}}`.
@ -299,7 +311,7 @@ pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), Stri
}
out.insert(k.clone(), normalize(s).unwrap_or_default());
}
Ok((v.get("set").and_then(Value::as_str).unwrap_or(CUSTOM).to_string(), out))
Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out))
}
// ---------- UI commands ----------
@ -370,7 +382,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
sync(&app.ui);
Ok(Value::Null)
}
"shortcuts.preset" => match s("name").as_deref().and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
"shortcuts.preset" => match s("name").as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
Some((n, p)) => {
app.ui.shortcut_overrides = p;
app.ui.shortcut_set = n;

View File

@ -229,7 +229,7 @@ pub struct UiState {
#[serde(default)]
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
/// Name of the shortcut set (preset or "Custom").
#[serde(default = "default_shortcut_set")]
#[serde(default = "default_shortcut_set", deserialize_with = "crate::shortcut_editor::deserialize_set_name")]
pub shortcut_set: String,
/// Current workspace (Window → Workspace).
#[serde(default = "default_workspace")]

View File

@ -0,0 +1,107 @@
//! Neutral wording in menus and panels: what users read names VectorCraft's own features.
use egui::epaint::Shape;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::menus::{self, Item};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app
}
/// Every string painted by `f` in one headless frame, one per line.
fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String {
fn collect(s: &Shape, out: &mut String) {
match s {
Shape::Text(t) => {
out.push_str(t.galley.text());
out.push('\n');
}
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui));
out.textures_delta.clear();
let mut text = String::new();
for c in &out.shapes {
collect(&c.shape, &mut text);
}
text
}
#[test]
fn effect_menu_has_a_vector_effects_section() {
let tree = menus::menu_tree();
let (_, effect) = tree.iter().find(|(t, _)| *t == "Effect").expect("Effect menu");
assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects"))));
}
#[test]
fn library_submenus_are_disabled_placeholders() {
let app = app();
let entries = menus::menu_entries(&app);
for lib in ["Brush Libraries", "Graphic Style Libraries", "Swatch Libraries", "Symbol Libraries"] {
let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect();
let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}");
assert!(items.iter().all(|e| !e.enabled && e.command.is_none()), "{lib}");
}
}
#[test]
fn stroke_profiles_have_descriptive_names() {
let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect();
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]);
}
#[test]
fn swatches_menu_offers_save_swatch_library() {
let mut app = app();
let text = painted_text(&mut app, crate::panels::swatches::menu);
assert!(text.contains("Save Swatch Library…"), "{text}");
assert!(!text.contains("ASE"), "{text}");
}
#[test]
fn edit_menu_lists_pdf_presets() {
let app = app();
assert!(menus::menu_entries(&app).iter().any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && !e.enabled));
}
#[test]
fn clipboard_preferences_name_the_legacy_format_neutrally() {
let label = |key: &str| vectorcraft_engine::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == key).unwrap().label;
assert_eq!(label("copyAicb"), "Legacy vector clipboard (no transparency)");
assert_eq!(label("aicbMode"), "Legacy vector clipboard");
}
#[test]
fn shortcut_set_old_name_is_accepted() {
use crate::shortcut_editor::{PRESETS, import_json};
let (old, _) = crate::shortcut_editor::LEGACY_PRESETS[0];
assert_eq!(PRESETS, ["VectorCraft Defaults", "Classic Defaults"]);
// UI preferences saved by an earlier version load (and save again) under the current name.
let ui: crate::state::UiState = serde_json::from_value(json!({ "shortcut_set": old })).unwrap();
assert_eq!(ui.shortcut_set, "Classic Defaults");
let saved = serde_json::to_value(&ui).unwrap();
assert_eq!(saved["shortcut_set"], "Classic Defaults");
let back: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert_eq!(back.shortcut_set, "Classic Defaults");
let default: crate::state::UiState = serde_json::from_value(json!({})).unwrap();
assert_eq!(default.shortcut_set, PRESETS[0]);
// The command and imported sets take the old name too.
let mut app = app();
app.run("shortcuts.preset", json!({ "name": old })).unwrap();
assert_eq!(app.ui.shortcut_set, "Classic Defaults");
app.run("shortcuts.preset", json!({ "name": "VectorCraft Defaults" })).unwrap();
assert_eq!(app.ui.shortcut_set, "VectorCraft Defaults");
assert!(app.run("shortcuts.preset", json!({ "name": "Nope" })).is_err());
let (set, _) = import_json(&json!({ "set": old, "overrides": {} })).unwrap();
assert_eq!(set, "Classic Defaults");
}

View File

@ -23,3 +23,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox. (The app's synchronous drop path is compiled out on wasm32.)
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
## Vendor names gate
`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and every text file under `docs/` and `packaging/`. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment.

View File

@ -64,7 +64,7 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | Nine graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |

View File

@ -10,5 +10,5 @@ Terminal=false
StartupNotify=true
StartupWMClass=ai.storyteller.vectorcraft
Categories=Graphics;2DGraphics;VectorGraphics;
Keywords=vector;illustration;svg;pdf;drawing;bezier;illustrator;
Keywords=vector;illustration;svg;pdf;drawing;bezier;
MimeType=image/svg+xml;application/pdf;

View File

@ -12,7 +12,9 @@
</developer>
<description>
<p>
VectorCraft is a vector graphics editor. Draw and edit vector illustrations, SVG and PDF
VectorCraft is an open-source vector illustration app. Draw with pen, shape and brush tools,
style art with fills, strokes, gradients and live effects, lay out artboards and type, and
open and save SVG and PDF.
</p>
</description>
<launchable type="desktop-id">ai.storyteller.vectorcraft.desktop</launchable>
@ -24,19 +26,21 @@
<url type="contact">https://discord.gg/artcraft</url>
<categories>
<category>Graphics</category>
<category>RasterGraphics</category>
<category>Photography</category>
<category>2DGraphics</category>
<category>VectorGraphics</category>
</categories>
<keywords>
<keyword>photo</keyword>
<keyword>image editor</keyword>
<keyword>psd</keyword>
<keyword>layers</keyword>
<keyword>vector</keyword>
<keyword>illustration</keyword>
<keyword>svg</keyword>
<keyword>pdf</keyword>
<keyword>drawing</keyword>
<keyword>bezier</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption>
<image>https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/vectorcraft-demo.jpg</image>
<caption>Editing the Neon Drive poster with a live glow effect in the Appearance panel</caption>
<image>https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/shot-1-neon.png</image>
</screenshot>
</screenshots>
<branding>

View File

@ -17,9 +17,9 @@ vendor: Learning Machines LLC
homepage: https://getartcraft.com/apps/vectorcraft
license: MIT OR Apache-2.0
description: |-
Open-source, native image editor that works the way Photoshop users expect.
Layers, masks, adjustment layers, layer styles, type and brushes; opens and
saves layered PSD/PSB files; 8/16/32-bit documents; GPU compositing (wgpu).
Open-source, native vector illustration app.
Pen, shape and brush tools, artboards, type, gradients, symbols and live
effects; opens and saves SVG and PDF.
Includes vectorcraft-cli, a headless converter and command runner.
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the

View File

@ -45,7 +45,7 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the VectorCraft authors. MIT OR Apache-2.0.</string>
<!-- Vectorcraft's own format. -->
<!-- VectorCraft's own format (.drawcraft is its name from before the rename). -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
@ -63,48 +63,11 @@
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pcraft</string></array>
<array><string>vectorcraft</string><string>drawcraft</string></array>
<key>public.mime-type</key>
<array><string>application/x-vectorcraft</string></array>
</dict>
</dict>
</array>
<!-- Types macOS may not know; the system's own declaration wins when it has one. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.photoshop-large-image</string>
<key>UTTypeDescription</key>
<string>Photoshop Large Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>psb</string></array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.vectorcraft.qoi</string>
<key>UTTypeDescription</key>
<string>QOI Image</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>qoi</string></array>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@ -55,7 +55,7 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
```html
<iframe
src="https://example.com/vectorcraft/"
title="VectorCraft image editor"
title="VectorCraft vector illustration app"
style="width: 100%; height: 720px; border: 0;"
allow="fullscreen; clipboard-read; clipboard-write"
allowfullscreen>

View File

@ -4,9 +4,8 @@
wix build vectorcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by VectorCraft,
and VectorCraft offered under "Open with" for PSD/PSB and the image formats it reads (without
taking over the user's default app for them).
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry (so
`vectorcraft` starts from the Run dialog), plus vectorcraft-cli.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
@ -17,7 +16,7 @@
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="VectorCraft image editor" Manufacturer="Learning Machines LLC" />
<SummaryInformation Description="VectorCraft vector illustration app" Manufacturer="Learning Machines LLC" />
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
@ -45,7 +44,7 @@
<Component Id="VectorcraftApp" Guid="0E8C6C5E-DE4A-4E1D-9DB0-44E8E38C2103">
<File Id="VectorcraftExe" Source="$(var.BinDir)\vectorcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="VectorCraft"
Description="VectorCraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Description="VectorCraft vector illustration app" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="VectorcraftIcon" IconIndex="0" />
</File>

View File

@ -5,7 +5,6 @@
//! as `` `path` `` in ASSETS.md. See the asset policy in AGENTS.md.
use std::path::Path;
use std::process::Command;
const ASSET_EXT: &[&str] = &[
"png",
@ -51,12 +50,7 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
}
pub fn run(root: &Path) -> Result<(), String> {
let out = Command::new("git")
.current_dir(root)
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
.output()
.map_err(|e| format!("git ls-files: {e}"))?;
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
let files = crate::repo_files(root)?;
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?;
let miss = missing(&files, &md);
if miss.is_empty() {

385
xtask/src/brands.rs Normal file
View File

@ -0,0 +1,385 @@
//! `cargo xtask brands`: no other vendor's product or company names in what users and agents read.
//!
//! VectorCraft names its own features; where a comparison is needed, text says "the reference
//! app". Scanned:
//! * string literals in Rust sources (command labels and params docs, `UI_COMMANDS`, menus, panels,
//! MCP tool definitions…). Comments and test code (`tests*.rs`, `*_tests.rs`, `tests/` and
//! `benches/` folders, items under `#[cfg(test)]`) are skipped;
//! * the `description` of every `Cargo.toml`;
//! * every text file under `docs/` and `packaging/`.
//!
//! A line that has to keep an old name (an alias that old files or preferences still use) says
//! `brand-ok` in a comment on that line.
use std::path::Path;
/// Vendor product and company names, lowercase, matched case-insensitively as whole words.
const DENY: &[&str] = &[
"adobe",
"illustrator",
"photoshop",
"acrobat",
"indesign",
"lightroom",
"dreamweaver",
"creative cloud",
"typekit",
"kuler",
"behance",
"myriad",
"pantone",
"aicb",
];
/// Marks a line that keeps a legacy name on purpose.
const ALLOW: &str = "brand-ok";
/// This file lists the names it rejects.
const SELF: &str = "xtask/src/brands.rs";
/// A denied name found in a file.
#[derive(Debug, PartialEq)]
pub struct Hit {
pub path: String,
/// 1-based.
pub line: usize,
pub term: &'static str,
/// The literal or line it was found in.
pub context: String,
}
pub fn run(root: &Path) -> Result<(), String> {
let files = crate::repo_files(root)?;
let hits = scan(root, &files);
if hits.is_empty() {
println!("brands: no vendor names in user-visible text");
return Ok(());
}
let list: Vec<String> = hits.iter().map(|h| format!("{}:{}: \"{}\" in {}", h.path, h.line, h.term, short(&h.context))).collect();
Err(format!(
"{} vendor name(s) in user-visible text (say \"the reference app\" or name the feature; mark a required legacy alias `{ALLOW}`):\n {}",
hits.len(),
list.join("\n ")
))
}
/// Every hit in `files` (paths relative to `root`).
pub fn scan(root: &Path, files: &[String]) -> Vec<Hit> {
let mut hits = vec![];
for path in files {
let Some(kind) = kind_of(path) else { continue };
// Binary files (images under docs/) are not text and have nothing to read.
let Ok(text) = std::fs::read_to_string(root.join(path)) else { continue };
let found = match kind {
Kind::Rust => scan_rust(&text),
Kind::Manifest => scan_lines(&text, |l| l.trim_start().starts_with("description")),
Kind::Text => scan_lines(&text, |_| true),
};
hits.extend(found.into_iter().map(|(line, term, context)| Hit { path: path.clone(), line, term, context }));
}
hits
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Rust,
Manifest,
Text,
}
fn kind_of(path: &str) -> Option<Kind> {
if path.starts_with("docs/") || path.starts_with("packaging/") {
Some(Kind::Text)
} else if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
Some(Kind::Manifest)
} else if path.ends_with(".rs") && ["crates/", "apps/", "xtask/"].iter().any(|d| path.starts_with(d)) && path != SELF && !is_test_path(path) {
Some(Kind::Rust)
} else {
None
}
}
fn is_test_path(path: &str) -> bool {
let mut parts = path.split('/').rev();
let file = parts.next().unwrap_or_default();
file == "tests.rs" || file.starts_with("tests_") || file.ends_with("_tests.rs") || parts.any(|d| d == "tests" || d == "benches")
}
/// (line, term, line text) for each denied name on the lines `keep` selects.
fn scan_lines(text: &str, keep: impl Fn(&str) -> bool) -> Vec<(usize, &'static str, String)> {
text.lines()
.enumerate()
.filter(|(_, l)| keep(l) && !l.contains(ALLOW))
.flat_map(|(i, l)| terms_in(l, DENY).into_iter().map(move |t| (i + 1, t, l.trim().to_string())))
.collect()
}
/// (first line, term, literal) for each denied name in a Rust string literal outside comments and
/// test code.
fn scan_rust(src: &str) -> Vec<(usize, &'static str, String)> {
let lines: Vec<&str> = src.lines().collect();
let mut out = vec![];
for lit in rust_literals(src) {
if lines.get(lit.first - 1..lit.last.min(lines.len())).is_some_and(|ls| ls.iter().any(|l| l.contains(ALLOW))) {
continue;
}
for t in terms_in(&lit.text, DENY) {
out.push((lit.first, t, lit.text.clone()));
}
}
out
}
/// The `terms` (lowercase) that occur in `text` as whole words, ignoring case.
fn terms_in(text: &str, terms: &[&'static str]) -> Vec<&'static str> {
let lower = text.to_ascii_lowercase();
let b = lower.as_bytes();
let word = |c: Option<&u8>| c.is_some_and(u8::is_ascii_alphanumeric);
terms
.iter()
.copied()
.filter(|t| lower.match_indices(t).any(|(i, _)| !word(i.checked_sub(1).and_then(|j| b.get(j))) && !word(b.get(i + t.len()))))
.collect()
}
fn short(s: &str) -> String {
let s = s.replace('\n', " ");
match s.char_indices().nth(100) {
Some((i, _)) => format!("{}…", &s[..i]),
None => s,
}
}
/// A Rust string literal: its first and last line (1-based) and its text (escapes as spaces).
#[derive(Debug)]
struct Lit {
first: usize,
last: usize,
text: String,
}
/// String literals of a Rust source, skipping comments and items under `#[cfg(test)]`.
fn rust_literals(src: &str) -> Vec<Lit> {
let b = src.as_bytes();
let mut out = vec![];
let (mut i, mut line) = (0usize, 1usize);
let mut depth = 0usize;
// `#[cfg(test)]` seen, its item not opened yet; brace depth the test item was opened at.
let (mut test_attr, mut test_depth) = (false, None::<usize>);
while i < b.len() {
match b[i] {
b'\n' => {
line += 1;
i += 1;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut nest = 0usize;
while i < b.len() {
if b[i..].starts_with(b"/*") {
nest += 1;
i += 2;
} else if b[i..].starts_with(b"*/") {
nest -= 1;
i += 2;
if nest == 0 {
break;
}
} else {
line += usize::from(b[i] == b'\n');
i += 1;
}
}
}
b'"' => {
let first = line;
let (text, end) = quoted(b, i + 1, &mut line);
i = end;
if test_depth.is_none() {
out.push(Lit { first, last: line, text });
}
}
b'\'' => i = after_char_or_lifetime(src, i),
b'#' if b[i..].starts_with(b"#[cfg(test)]") => {
test_attr = true;
i += "#[cfg(test)]".len();
}
b'{' => {
if test_attr && test_depth.is_none() {
test_depth = Some(depth);
}
test_attr = false;
depth += 1;
i += 1;
}
b'}' => {
depth = depth.saturating_sub(1);
if test_depth == Some(depth) {
test_depth = None;
}
i += 1;
}
b';' => {
test_attr = false;
i += 1;
}
c if c.is_ascii_alphabetic() || c == b'_' => {
let start = i;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
i += 1;
}
let hashes = b[i..].iter().take_while(|&&h| h == b'#').count();
// Raw strings (r"…", r#"…"#, br…, cr…); b"…" and c"…" are read as plain strings next.
if matches!(&src[start..i], "r" | "br" | "cr") && b.get(i + hashes) == Some(&b'"') {
let first = line;
let body = i + hashes + 1;
let close: Vec<u8> = std::iter::once(b'"').chain(std::iter::repeat_n(b'#', hashes)).collect();
let end = (body..b.len()).find(|&j| b[j..].starts_with(&close)).unwrap_or(b.len());
let text = String::from_utf8_lossy(&b[body..end]).into_owned();
line += text.matches('\n').count();
i = (end + close.len()).min(b.len());
if test_depth.is_none() {
out.push(Lit { first, last: line, text });
}
}
}
_ => i += 1,
}
}
out
}
/// Read a `"…"` literal whose body starts at `i`: (text with escapes as spaces, index after the
/// closing quote).
fn quoted(b: &[u8], mut i: usize, line: &mut usize) -> (String, usize) {
let mut text = Vec::new();
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
match b.get(i + 1) {
Some(&c @ (b'"' | b'\\' | b'\'')) => text.push(c),
Some(b'\n') => {
*line += 1;
text.push(b' ');
}
_ => text.push(b' '),
}
i += 2;
} else {
*line += usize::from(b[i] == b'\n');
text.push(b[i]);
i += 1;
}
}
(String::from_utf8_lossy(&text).into_owned(), (i + 1).min(b.len()))
}
/// Skip a char literal (`'x'`, `'\n'`, `'\u{…}'`) at `i`, or just the quote of a lifetime/label.
fn after_char_or_lifetime(src: &str, i: usize) -> usize {
let b = src.as_bytes();
if b.get(i + 1) == Some(&b'\\') {
let close = (i + 3..b.len()).find(|&j| b[j] == b'\'').unwrap_or(b.len());
return (close + 1).min(b.len());
}
match src[i + 1..].chars().next() {
Some(c) if b.get(i + 1 + c.len_utf8()) == Some(&b'\'') => i + 2 + c.len_utf8(),
_ => i + 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rust_hits(src: &str) -> Vec<(usize, &'static str)> {
scan_rust(src).into_iter().map(|(l, t, _)| (l, t)).collect()
}
#[test]
fn rust_literals_skip_comments_identifiers_tests_and_marked_lines() {
let src = r##"// Illustrator in a comment
/// Adobe in a doc comment
/* block Adobe /* nested */ Photoshop */
fn f() { let a = "Made for Illustrator users"; let k = "copyAicb"; let q = '"'; let l: &'static str = r#"raw "Acrobat" text"#; }
const OLD: &str = "Illustrator Defaults"; // brand-ok: legacy alias
fn g() -> &'static str { "line\nAICB" }
#[cfg(test)]
mod tests {
fn t() { let x = "Adobe"; if true { let y = "Photoshop"; } }
}
#[cfg(test)]
mod other;
fn h() { let s = b"InDesign"; let m = "multi
line Pantone"; }
"##;
assert_eq!(rust_hits(src), [(4, "illustrator"), (4, "acrobat"), (6, "aicb"), (13, "indesign"), (13, "pantone")]);
let lits = rust_literals(src);
let multi = lits.iter().find(|l| l.text.contains("Pantone")).unwrap();
assert_eq!((multi.first, multi.last), (13, 14));
}
#[test]
fn whole_words_only() {
assert_eq!(terms_in("Adobe's app", DENY), ["adobe"]);
assert_eq!(terms_in("an ILLUSTRATOR-style UI", DENY), ["illustrator"]);
assert!(terms_in("illustrators illustration copyAicb aicbMode", DENY).is_empty());
}
#[test]
fn docs_packaging_and_manifests() {
let lines = |text: &str, keep: fn(&str) -> bool| -> Vec<(usize, &'static str)> {
scan_lines(text, keep).into_iter().map(|(l, t, _)| (l, t)).collect()
};
assert_eq!(lines("Keywords=vector;illustrator;\n", |_| true), [(1, "illustrator")]);
assert_eq!(lines("Like Adobe apps\nnot here: Adobe # brand-ok\n", |_| true), [(1, "adobe")]);
let manifest = "name = \"adobe-thing\"\ndescription = \"Illustrator-style UI\"\n";
assert_eq!(lines(manifest, |l| l.trim_start().starts_with("description")), [(2, "illustrator")]);
}
#[test]
fn scope() {
assert_eq!(kind_of("crates/engine/src/cmd/file.rs"), Some(Kind::Rust));
assert_eq!(kind_of("crates/engine/src/tests_file.rs"), None);
assert_eq!(kind_of("crates/tools/src/text_tests.rs"), None);
assert_eq!(kind_of("crates/color/src/cms/tests.rs"), None);
assert_eq!(kind_of("crates/engine/tests/command_sweep.rs"), None);
assert_eq!(kind_of(SELF), None);
assert_eq!(kind_of("crates/ui-egui/Cargo.toml"), Some(Kind::Manifest));
assert_eq!(kind_of("docs/mcp.md"), Some(Kind::Text));
assert_eq!(kind_of("packaging/linux/nfpm.yaml"), Some(Kind::Text));
for skipped in ["README.md", "ROADMAP.md", "CLAUDE.md", "AGENTS.md", ".github/workflows/release.yml", "ASSETS.md"] {
assert_eq!(kind_of(skipped), None, "{skipped}");
}
}
#[test]
fn injected_strings_fail_the_gate() {
let dir = std::env::temp_dir().join(format!("vc-brands-{}", std::process::id()));
let file = |p: &str, text: &str| {
let path = dir.join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
};
file("crates/x/src/lib.rs", "pub const LABEL: &str = \"Export for Illustrator\"; // a comment: Adobe\n");
file("docs/x.md", "Works like Photoshop.\n");
file("packaging/x.desktop", "Keywords=vector;illustrator;\n");
file("README.md", "Adobe trademark notice\n");
let files: Vec<String> = ["crates/x/src/lib.rs", "docs/x.md", "packaging/x.desktop", "README.md"].map(String::from).to_vec();
let hits = scan(&dir, &files);
let _ = std::fs::remove_dir_all(&dir);
let found: Vec<(&str, usize, &str)> = hits.iter().map(|h| (h.path.as_str(), h.line, h.term)).collect();
assert_eq!(found, [("crates/x/src/lib.rs", 1, "illustrator"), ("docs/x.md", 1, "photoshop"), ("packaging/x.desktop", 1, "illustrator")]);
}
#[test]
fn the_tree_is_clean() {
let root = crate::root();
// Outside a git checkout there is no file list to check.
let Ok(files) = crate::repo_files(&root) else { return };
let hits = scan(&root, &files);
assert!(hits.is_empty(), "{hits:#?}");
}
}

View File

@ -21,8 +21,14 @@ pub fn run(root: &Path) -> Result<(), String> {
std::fs::copy(target.join("release/vectorcraft"), app.join("MacOS/VectorCraft")).map_err(|e| format!("copy app: {e}"))?;
std::fs::copy(target.join("release/vectorcraft-cli"), app.join("MacOS/vectorcraft-cli")).map_err(|e| format!("copy cli: {e}"))?;
std::fs::copy(root.join("assets/app-icon/vectorcraft.icns"), app.join("Resources/VectorCraft.icns")).map_err(|e| format!("copy icon: {e}"))?;
let version = env!("CARGO_PKG_VERSION");
let plist = format!(
std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"))).map_err(|e| e.to_string())?;
println!("built {}", root.join("dist/VectorCraft.app").display());
Ok(())
}
/// The development bundle's `Info.plist`.
fn info_plist(version: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
@ -39,12 +45,52 @@ pub fn run(root: &Path) -> Result<(), String> {
<key>CFBundleDocumentTypes</key><array>
<dict><key>CFBundleTypeName</key><string>VectorCraft Document</string><key>CFBundleTypeExtensions</key><array><string>vectorcraft</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>SVG</string><key>CFBundleTypeExtensions</key><array><string>svg</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>PDF / Illustrator</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
<dict><key>CFBundleTypeName</key><string>PDF Document</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
</array>
</dict></plist>
"#
);
std::fs::write(app.join("Info.plist"), plist).map_err(|e| e.to_string())?;
println!("built {}", root.join("dist/VectorCraft.app").display());
Ok(())
)
}
#[cfg(test)]
mod tests {
use super::*;
/// File types and wording a raster image editor's packaging carries (lowercase).
const RASTER_EDITOR: &[&str] = &["pcraft", "psd", "psb", "qoi", "photo", "rastergraphics", "image editor"];
fn text_files(dir: &Path, out: &mut Vec<(String, String)>) {
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
text_files(&p, out);
} else if let Ok(t) = std::fs::read_to_string(&p) {
out.push((p.display().to_string(), t.to_ascii_lowercase()));
}
}
}
#[test]
fn packaging_describes_a_vector_app() {
let mut files = vec![];
text_files(&crate::root().join("packaging"), &mut files);
assert!(files.len() > 5, "packaging files found");
for (path, text) in &files {
for w in RASTER_EDITOR {
assert!(!text.contains(w), "{path} mentions `{w}`");
}
}
let read = |p: &str| std::fs::read_to_string(crate::root().join(p)).unwrap();
let plist = read("packaging/macos/Info.plist.in");
assert!(plist.contains("<array><string>vectorcraft</string><string>drawcraft</string></array>"), "native format extensions");
assert!(read("packaging/linux/ai.storyteller.vectorcraft.desktop").contains("Categories=Graphics;2DGraphics;VectorGraphics;"));
assert!(read("packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in").contains("<category>VectorGraphics</category>"));
}
#[test]
fn dev_bundle_plist_names_types_neutrally() {
let p = info_plist("1.2.3");
assert!(p.contains("<key>CFBundleShortVersionString</key><string>1.2.3</string>"));
assert!(p.contains("<string>VectorCraft Document</string>") && p.contains("<string>PDF Document</string>"));
}
}

View File

@ -4,6 +4,7 @@
//! invoked through `std::process::Command`.
mod assets;
mod brands;
mod bundle;
mod ico;
mod layers;
@ -18,9 +19,10 @@ usage: cargo xtask <command>
commands:
assets check that every icon/image/font/asset is attributed in ASSETS.md
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
@ -34,6 +36,7 @@ fn main() -> ExitCode {
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()),
Some("brands") => brands::run(&root()),
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
@ -62,6 +65,19 @@ pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
/// Tracked and untracked (not ignored) files that exist, relative to `root`.
pub fn repo_files(root: &Path) -> Result<Vec<String>, String> {
let out = Command::new("git")
.current_dir(root)
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
.output()
.map_err(|e| format!("git ls-files: {e}"))?;
if !out.status.success() {
return Err(format!("git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect())
}
pub fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
@ -176,6 +192,7 @@ fn cmd_ci() -> Result<(), String> {
}),
),
("assets", Box::new(|| assets::run(&root()))),
("brands", Box::new(|| brands::run(&root()))),
("layers", Box::new(cmd_layers)),
("wasm", Box::new(cmd_wasm)),
];