diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 1cdbe340..4b65b3f3 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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!( diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 571c74df..02ef3cff 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 57ebb88c..6b168481 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -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 \ diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 09b90610..c8366db6 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -302,7 +302,7 @@ pub fn tool_definitions() -> Vec { 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 { 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"]}, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 889f2224..ed602a51 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7de7893d..1d56029b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8408069c..cf7fc14a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -98,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"), @@ -750,7 +755,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { 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"), diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 6f9381ca..e55e388f 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -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). +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 { + ::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, key: &str) { /// Overrides of a preset (both presets are the registry defaults for now). pub fn preset(name: &str) -> Option> { - 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), 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 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; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 72c26630..5cdbbcdd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -229,7 +229,7 @@ pub struct UiState { #[serde(default)] pub shortcut_overrides: std::collections::BTreeMap, /// 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")] diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 02ce2424..b67fc18b 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -67,3 +67,41 @@ fn swatches_menu_offers_save_swatch_library() { 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}; + const OLD: &str = "Illustrator Defaults"; // brand-ok: legacy preference value under test + 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"); +} diff --git a/docs/development.md b/docs/development.md index d77f990f..7fe3f9a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index 305924c6..56c83df2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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` | `{}` | | diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 2f66217b..1f3f79d2 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -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 { } 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::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() { diff --git a/xtask/src/brands.rs b/xtask/src/brands.rs new file mode 100644 index 00000000..a6c5cd51 --- /dev/null +++ b/xtask/src/brands.rs @@ -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 = 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 { + 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 { + 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 { + 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::); + 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 = 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 = ["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:#?}"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 3c54db8b..fd34b464 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 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, 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)), ];