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:
commit
115255a7e4
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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"]},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` | `{}` | |
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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:#?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue