From 3c85f3f9e3e3e43b1080855bab6eb9f2c78de8ba Mon Sep 17 00:00:00 2001 From: Ahmed Moussa Date: Fri, 9 Oct 2026 16:09:34 +0300 Subject: [PATCH 01/43] feat(text): load craft-fonts' Arabic families, fetch them on web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop builds made with CRAFT_FONTS_DIR now load craft-fonts' 12 Arabic families into the document font database. Noto Sans Arabic comes first and is the fallback for Arabic text. Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar can be picked by name from the font menus. The web build doesn't embed these fonts because they would make the wasm too large. It fetches them from beside the wasm instead: - crates/text/web-fonts.txt lists each face as startup or background. - build.rs turns that list into vectorcraft_text::WEB_FONTS, with each face's URL (fonts//) and SRI hash taken from the craft-fonts manifest. - packaging/web/copy-fonts.sh, a Trunk post_build hook, copies the font files and their OFL licences there. - apps/vectorcraft-web/src/fonts.rs fetches Noto Sans Arabic before the app starts, so Arabic text never shows as boxes. It loads the other faces in the background and logs `web font …` for any that fail. Because fonts can now arrive after startup, the Type tool's layout cache also checks the font database's generation, so text lays out again when new fonts arrive. _headers and .htaccess serve fonts/* as font/ttf and cache it as immutable. packaging/web/README.md and docs/development.md explain the fonts folder, and ROADMAP.md lists the Arabic typefaces under Type. Also adds Ahmed Moussa to contributors/people.toml. Signed-off-by: Ahmed Moussa --- Cargo.lock | 1 + ROADMAP.md | 2 +- apps/vectorcraft-web/Cargo.toml | 3 +- apps/vectorcraft-web/Trunk.toml | 7 ++ apps/vectorcraft-web/src/fonts.rs | 109 +++++++++++++++++++++++++++ apps/vectorcraft-web/src/main.rs | 2 + apps/vectorcraft-web/src/web.rs | 4 + contributors/people.toml | 5 ++ crates/text/build.rs | 72 ++++++++++++++++-- crates/text/build/web_fonts.rs | 118 ++++++++++++++++++++++++++++++ crates/text/src/craft_fonts.rs | 61 ++++++++++++++- crates/text/src/fontdb.rs | 7 +- crates/text/src/lib.rs | 5 +- crates/text/src/tests_bidi.rs | 18 +++++ crates/text/web-fonts.txt | 36 +++++++++ crates/tools/Cargo.toml | 1 + crates/tools/src/text.rs | 14 +++- crates/tools/src/text_tests.rs | 12 +++ docs/development.md | 17 +++++ packaging/web/.htaccess | 5 +- packaging/web/README.md | 13 ++++ packaging/web/_headers | 4 + packaging/web/copy-fonts.sh | 31 ++++++++ 23 files changed, 527 insertions(+), 20 deletions(-) create mode 100644 apps/vectorcraft-web/src/fonts.rs create mode 100644 crates/text/build/web_fonts.rs create mode 100644 crates/text/web-fonts.txt create mode 100755 packaging/web/copy-fonts.sh diff --git a/Cargo.lock b/Cargo.lock index 2da7e754..478ab921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4755,6 +4755,7 @@ dependencies = [ "rfd", "vectorcraft-engine", "vectorcraft-format", + "vectorcraft-text", "vectorcraft-ui-egui", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/ROADMAP.md b/ROADMAP.md index 5c167768..b2608c62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: craft-fonts' 12 Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar) are in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index fa804f18..811b937a 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" vectorcraft-engine = { workspace = true } vectorcraft-ui-egui = { workspace = true } vectorcraft-format = { workspace = true } +vectorcraft-text = { workspace = true } egui = { workspace = true } log = { workspace = true } @@ -27,4 +28,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] } diff --git a/apps/vectorcraft-web/Trunk.toml b/apps/vectorcraft-web/Trunk.toml index 5683fc5a..f468dc13 100644 --- a/apps/vectorcraft-web/Trunk.toml +++ b/apps/vectorcraft-web/Trunk.toml @@ -8,3 +8,10 @@ filehash = true [serve] addresses = ["127.0.0.1"] port = 8766 + +# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/ +# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`. +[[hooks]] +stage = "post_build" +command = "sh" +command_arguments = ["../../packaging/web/copy-fonts.sh"] diff --git a/apps/vectorcraft-web/src/fonts.rs b/apps/vectorcraft-web/src/fonts.rs new file mode 100644 index 00000000..36c7a380 --- /dev/null +++ b/apps/vectorcraft-web/src/fonts.rs @@ -0,0 +1,109 @@ +//! Craft-fonts faces served beside the wasm (`fonts//…`, copied by +//! `packaging/web/copy-fonts.sh`) instead of embedded: [`load_startup_fonts`] fetches the `startup` +//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks +//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped: +//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing. + +use std::cell::Cell; +use std::rc::Rc; + +use vectorcraft_text::{FontDb, WEB_FONTS, WebFont}; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::closure::Closure; +use wasm_bindgen_futures::JsFuture; + +/// Per startup face: the page shows its loading text meanwhile. +const STARTUP_TIMEOUT_MS: i32 = 20_000; +/// Per background face. +const BACKGROUND_TIMEOUT_MS: i32 = 120_000; +/// Background fetches at once, leaving the connection to the app's own requests. +const BACKGROUND_PARALLEL: usize = 4; + +thread_local! { + /// The background fetch runs once per page, though a graphics restart creates the app again. + static BACKGROUND_STARTED: Cell = const { Cell::new(false) }; +} + +/// Fetch and register the `startup` faces (one after the other). Returns how many loaded. +pub async fn load_startup_fonts() -> usize { + let mut loaded = 0; + for f in WEB_FONTS.iter().filter(|f| f.startup) { + if load(f, STARTUP_TIMEOUT_MS).await { + loaded += 1; + } + } + loaded +} + +/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for +/// a frame after each so the font menus and the canvas pick it up. Only the first call does +/// anything. +pub fn spawn_background_fonts(ctx: egui::Context) { + if BACKGROUND_STARTED.with(|s| s.replace(true)) { + return; + } + let queue: Rc> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect()); + let next = Rc::new(Cell::new(0usize)); + for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) { + let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + loop { + let i = next.get(); + next.set(i + 1); + let Some(f) = queue.get(i) else { break }; + if load(f, BACKGROUND_TIMEOUT_MS).await { + ctx.request_repaint(); + } + } + }); + } +} + +/// Fetch one face and add it to the font database. False (and a console warning) on failure. +async fn load(f: &WebFont, timeout_ms: i32) -> bool { + let bytes = match fetch(f.url, f.integrity, timeout_ms).await { + Ok(bytes) => bytes, + Err(e) => { + log::warn!("web font {} {}: {e}", f.family, f.style); + return false; + } + }; + if FontDb::global().add_font(bytes) == 0 { + log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style); + return false; + } + true +} + +/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after +/// `timeout_ms`. +async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result, String> { + let window = web_sys::window().ok_or("no window")?; + let abort = web_sys::AbortController::new().map_err(js)?; + let init = web_sys::RequestInit::new(); + init.set_method("GET"); + init.set_integrity(integrity); + init.set_signal(Some(&abort.signal())); + let on_timeout = { + let abort = abort.clone(); + Closure::::new(move || abort.abort()) + }; + let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?; + let result = async { + let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?; + if !response.ok() { + return Err(format!("HTTP {} for {url}", response.status())); + } + let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) + } + .await; + window.clear_timeout_with_handle(timer); + // Kept alive until the timer can no longer fire. + drop(on_timeout); + result +} + +fn js(e: wasm_bindgen::JsValue) -> String { + format!("{e:?}") +} diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index ca7ef832..fdf41991 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -14,6 +14,8 @@ //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any //! language code) stands in for the browser's languages when the interface language is Automatic. +#[cfg(target_arch = "wasm32")] +mod fonts; #[cfg(target_arch = "wasm32")] mod locks; #[cfg(target_arch = "wasm32")] diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index feeb9296..b9ca1432 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -64,6 +64,9 @@ pub fn start() { wasm_bindgen_futures::spawn_local(async move { // Before the first frame, which looks for copies a crash left behind. let locks = WebLocks::start(RECOVERY_PREFIX).await; + // Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text + // shows; the rest load in the background once the app runs. + crate::fonts::load_startup_fonts().await; let host = Host { runner: eframe::WebRunner::new(), inbox: Arc::default(), @@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option/` beside the wasm instead of embedding (`packaging/web/copy-fonts.sh` copies them +//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is a +//! warning, or an error with `CRAFT_FONTS_REQUIRED=1`, and never costs the desktop its fonts. use std::fmt::Write as _; use std::path::{Path, PathBuf}; +#[path = "build/web_fonts.rs"] +mod web_fonts; + fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); - let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + println!("cargo::rerun-if-changed=web-fonts.txt"); + println!("cargo::rerun-if-changed=build/web_fonts.rs"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let (mut fonts, mut web) = (String::new(), String::new()); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) { let dir = from_workspace(dir); match craft_fonts(&dir) { - Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Ok(entries) => fonts = entries, + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + // Separately: a bad web list must not cost the desktop build its craft fonts. A listed face + // the checkout lacks (an older craft-fonts pin) is left out with a warning, never an error, + // so the apps don't depend on which craft-fonts commit CI pins: that build just fetches + // fewer fonts. + match web_font_entries(&dir) { + Ok((entries, missing)) => { + web = entries; + if !missing.is_empty() { + let msg = format!("{} not in CRAFT_FONTS_DIR={} (an older craft-fonts?)", missing.join(", "), dir.display()); + println!("cargo::warning=web build won't fetch: {msg}"); + } + } + Err(e) if required => println!("cargo::error=web-fonts.txt: {e}"), + Err(e) => println!("cargo::warning=web build fetches no craft fonts: web-fonts.txt: {e}"), + } } - src.push_str("];\n"); + let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); if let Err(e) = std::fs::write(&out, src) { println!("cargo::error=writing {}: {e}", out.display()); @@ -72,3 +96,37 @@ fn craft_fonts(dir: &Path) -> Result { } Ok(out) } + +/// One `WebFont { .. }` initialiser per `web-fonts.txt` line, from its craft-fonts manifest row, and +/// the listed faces the manifest lacks (an older craft-fonts checkout), which are left out. +fn web_font_entries(dir: &Path) -> Result<(String, Vec), String> { + let list_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("web-fonts.txt"); + let list = std::fs::read_to_string(&list_path).map_err(|e| format!("{}: {e}", list_path.display()))?; + let manifest_path = dir.join("fonts/manifest.txt"); + // WEB_FONTS follows the manifest (URLs and hashes), on every target. + println!("cargo::rerun-if-changed={}", manifest_path.display()); + let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let rows: Vec> = + manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect(); + let (mut out, mut missing) = (String::new(), Vec::new()); + for e in web_fonts::parse_list(&list)? { + let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else { + missing.push(format!("{} {}", e.family, e.style)); + continue; + }; + let (Some(file), Some(sha)) = (row.get(2), row.get(6)) else { + return Err(format!("malformed manifest row for {} {}", e.family, e.style)); + }; + let integrity = web_fonts::sri(sha).ok_or_else(|| format!("bad sha256 for {} {}", e.family, e.style))?; + let _ = writeln!( + out, + " WebFont {{ family: {:?}, style: {:?}, startup: {}, url: {:?}, integrity: {:?} }},", + e.family, + e.style, + e.startup, + web_fonts::url(sha, file), + integrity, + ); + } + Ok((out, missing)) +} diff --git a/crates/text/build/web_fonts.rs b/crates/text/build/web_fonts.rs new file mode 100644 index 00000000..94dcddb5 --- /dev/null +++ b/crates/text/build/web_fonts.rs @@ -0,0 +1,118 @@ +//! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the +//! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included +//! by both, so it has no dependencies). `packaging/web/copy-fonts.sh` must agree with [`url`]. + +/// One line of `web-fonts.txt`: `when | family | style`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Listed<'a> { + /// `startup` (fetched before the app starts) rather than `background`. + pub startup: bool, + pub family: &'a str, + pub style: &'a str, +} + +/// Parse `web-fonts.txt`. Blank lines and `#` comments are skipped; line numbers are 1-based. +pub fn parse_list(text: &str) -> Result>, String> { + let mut out = Vec::new(); + for (i, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [when, family, style] = fields.as_slice() else { + return Err(format!("web-fonts.txt line {}: expected `when | family | style`", i + 1)); + }; + let startup = match *when { + "startup" => true, + "background" => false, + other => return Err(format!("web-fonts.txt line {}: `{other}` is neither startup nor background", i + 1)), + }; + if family.is_empty() || style.is_empty() { + return Err(format!("web-fonts.txt line {}: empty family or style", i + 1)); + } + out.push(Listed { startup, family, style }); + } + Ok(out) +} + +/// Standard base64 (RFC 4648, with padding). +pub fn base64(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0)); + let n = (b(0) << 16) | (b(1) << 8) | b(2); + for k in 0..4 { + if k <= chunk.len() { + let six = ((n >> (18 - 6 * k)) & 63) as usize; + s.push(TABLE.get(six).map_or('=', |&c| char::from(c))); + } else { + s.push('='); + } + } + } + s +} + +/// The Subresource Integrity value (`sha256-`) for a 64-digit hex SHA-256; `None` if `hex` +/// isn't one. +pub fn sri(hex: &str) -> Option { + if hex.len() != 64 { + return None; + } + let bytes: Option> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect(); + Some(format!("sha256-{}", base64(&bytes?))) +} + +/// The site-relative URL a face is served at: `fonts//`. Relative, so the site works under any path; content-addressed, so it can be cached +/// forever. +pub fn url(sha256: &str, file: &str) -> String { + let name = file.rsplit('/').next().unwrap_or(file); + format!("fonts/{}/{name}", sha256.get(..16).unwrap_or(sha256)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_matches_rfc_4648_vectors() { + for (input, want) in + [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(base64(input.as_bytes()), want, "{input:?}"); + } + } + + #[test] + fn sri_of_the_empty_files_sha256() { + let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + assert_eq!(sri(empty).as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")); + assert_eq!(sri("abc"), None); + assert_eq!(sri(&"zz".repeat(32)), None); + } + + #[test] + fn url_is_relative_and_content_addressed() { + let sha = "63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e"; + assert_eq!(url(sha, "fonts/noto-sans-arabic/NotoSansArabic.ttf"), "fonts/63111b5b2e074dd4/NotoSansArabic.ttf"); + assert!(!url(sha, "x.ttf").starts_with('/')); + } + + #[test] + fn list_parsing() { + let l = parse_list("# c\n\nstartup | Noto Sans Arabic | Regular\nbackground | Amiri | Bold Italic\n").unwrap(); + assert_eq!( + l, + [ + Listed { startup: true, family: "Noto Sans Arabic", style: "Regular" }, + Listed { startup: false, family: "Amiri", style: "Bold Italic" } + ] + ); + assert!(parse_list("soon | A | B").unwrap_err().contains("line 1")); + assert!(parse_list("startup | A").is_err()); + assert!(parse_list("startup | | B").is_err()); + } +} diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index 00b94270..715a73ed 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -1,7 +1,7 @@ //! Fonts from the optional craft-fonts build input (). //! //! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its -//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese +//! manifest (Japanese UI and document fonts, and Arabic fonts); built without it, it is empty and Japanese //! text falls back to the installed system fonts. Release builds always set it. See `build.rs` //! and craftrules `standards/fonts.md`. @@ -14,6 +14,20 @@ pub struct CraftFont { pub bytes: &'static [u8], } +/// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding, +/// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by +/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `packaging/web/copy-fonts.sh`. +pub struct WebFont { + pub family: &'static str, + pub style: &'static str, + /// Fetched before the app starts (a fallback the first frame needs), else in the background. + pub startup: bool, + /// Site-relative URL, `fonts//`. + pub url: &'static str, + /// Subresource Integrity value, `sha256-`; the browser rejects other bytes. + pub integrity: &'static str, +} + include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); impl CraftFont { @@ -21,6 +35,10 @@ impl CraftFont { pub fn is_japanese(&self) -> bool { self.scripts.contains(&"Jpan") } + /// Is the font for Arabic text? + pub fn is_arabic(&self) -> bool { + self.scripts.contains(&"Arab") + } /// A serif (Mincho) family, for document text. pub fn is_mincho(&self) -> bool { self.family.contains("Mincho") @@ -43,6 +61,16 @@ pub fn japanese_document_fonts() -> Vec<&'static CraftFont> { v } +/// The craft-fonts faces the document font database loads: the Japanese ones in +/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic +/// first: the fallback for Arabic text; the other families are picked by name). Empty when built +/// without craft-fonts. +pub fn document_fonts() -> Vec<&'static CraftFont> { + let mut v = japanese_document_fonts(); + v.extend(CRAFT_FONTS.iter().filter(|f| f.is_arabic() && !f.is_japanese())); + v +} + /// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then /// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts. pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { @@ -50,3 +78,34 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold)); v } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_fonts_follow_the_list() { + if CRAFT_FONTS.is_empty() { + eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + return; + } + // The listed faces this craft-fonts checkout has (an older one lacks some; build.rs leaves + // those out with a warning). + let listed: Vec<_> = crate::web_fonts_build::parse_list(include_str!("../web-fonts.txt")) + .unwrap() + .into_iter() + .filter(|l| CRAFT_FONTS.iter().any(|f| f.family == l.family && f.style == l.style)) + .collect(); + assert_eq!(WEB_FONTS.len(), listed.len()); + for (l, w) in listed.iter().zip(WEB_FONTS) { + assert_eq!((l.family, l.style, l.startup), (w.family, w.style, w.startup)); + let (dir, name) = w.url.strip_prefix("fonts/").and_then(|r| r.split_once('/')).unwrap(); + assert!(dir.len() == 16 && dir.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "{}", w.url); + assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url); + assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity); + } + let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect(); + let noto = CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic"); + assert_eq!(startup, if noto { vec!["Noto Sans Arabic"] } else { vec![] }); + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 58841f63..f603f14f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1097,9 +1097,10 @@ impl FontDb { fn new(font_paths: FontPaths) -> Self { let mut faces = Vec::new(); - // The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho - // first: fallbacks for Japanese text after the requested and bundled fonts. - let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes); + // The bundled fonts, then the craft-fonts faces (when built with them): Japanese, Mincho + // first, then Arabic with Noto Sans Arabic first. They are fallbacks for those scripts + // after the requested and bundled fonts, and families users can pick by name. + let craft = crate::craft_fonts::document_fonts().into_iter().map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { for (i, spec) in enumerate_faces(data) { if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 740891de..adbfa87d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -22,7 +22,7 @@ mod shape; pub mod test_fonts; pub mod thread; -pub use craft_fonts::{CRAFT_FONTS, CraftFont}; +pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs, @@ -521,3 +521,6 @@ mod tests_typo; mod tests_variable; #[cfg(test)] mod tests_vertical; +#[cfg(test)] +#[path = "../build/web_fonts.rs"] +mod web_fonts_build; diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index cc03d8a9..3aac854c 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -344,3 +344,21 @@ fn paragraph_direction_is_per_paragraph() { assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1); assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0); } + +#[test] +fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() { + let arabic: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")).collect(); + if arabic.is_empty() { + eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + return; + } + let db = FontDb::with_font_dirs(vec![]); + for f in &arabic { + assert!(db.has_family(f.family), "{} {} isn't loaded", f.family, f.style); + } + let beh = db.face_covering('ب').expect("some face covers U+0628"); + assert_eq!(beh.family, "Noto Sans Arabic"); + // Latin still falls back to a bundled face, not to one of the Arabic families. + let a = db.face_covering('a').expect("some face covers 'a'"); + assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family); +} diff --git a/crates/text/web-fonts.txt b/crates/text/web-fonts.txt new file mode 100644 index 00000000..5cd21e94 --- /dev/null +++ b/crates/text/web-fonts.txt @@ -0,0 +1,36 @@ +# Craft-fonts faces the web (wasm32) build fetches from beside the wasm instead of embedding. +# One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt). +# startup = fetched before the app starts (fallbacks the first frame needs) +# background = fetched after the app starts (fonts users pick by name) +# Read by build.rs (the WEB_FONTS table) and packaging/web/copy-fonts.sh (copies the files). +startup | Noto Sans Arabic | Regular +background | Cairo | Regular +background | Tajawal | ExtraLight +background | Tajawal | Light +background | Tajawal | Regular +background | Tajawal | Medium +background | Tajawal | Bold +background | Tajawal | ExtraBold +background | Tajawal | Black +background | Almarai | Light +background | Almarai | Regular +background | Almarai | Bold +background | Almarai | ExtraBold +background | IBM Plex Sans Arabic | Thin +background | IBM Plex Sans Arabic | ExtraLight +background | IBM Plex Sans Arabic | Light +background | IBM Plex Sans Arabic | Regular +background | IBM Plex Sans Arabic | Medium +background | IBM Plex Sans Arabic | SemiBold +background | IBM Plex Sans Arabic | Bold +background | Noto Kufi Arabic | Regular +background | Amiri | Regular +background | Amiri | Italic +background | Amiri | Bold +background | Amiri | Bold Italic +background | Alexandria | Regular +background | Fustat | Regular +background | Reem Kufi | Regular +background | Aref Ruqaa | Regular +background | Aref Ruqaa | Bold +background | Gulzar | Regular diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 7717de87..4731d2c9 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -20,3 +20,4 @@ serde_json = { workspace = true } [dev-dependencies] image = { workspace = true } +vectorcraft-text = { workspace = true, features = ["test-fonts"] } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index ad985eef..5646b905 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -59,6 +59,9 @@ struct Preedit { active: Option>, } +/// The edited text's layout, keyed by the font database's generation and the text. +type CachedLayout = ((u64, TextObject), Arc); + #[derive(Default)] pub struct TypeTool { vertical: bool, @@ -86,7 +89,7 @@ pub struct TypeTool { clicks: (Option, u8), /// Styled copy of the last copied range (pasting the same plain text keeps its styles). clipboard: Vec, - cache: Mutex)>>, + cache: Mutex>, } impl TypeTool { @@ -106,16 +109,19 @@ impl TypeTool { } } - /// Layout of `t`, cached by content (overlays run every frame). + /// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the + /// web build adds fonts after startup). fn layout(&self, t: &TextObject) -> Arc { + let fonts = FontDb::global().generation(); let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - if let Some((k, l)) = c.as_ref() + if let Some(((g, k), l)) = c.as_ref() + && *g == fonts && k == t { return l.clone(); } let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t)); - *c = Some((t.clone(), l.clone())); + *c = Some(((fonts, t.clone()), l.clone())); l } diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 9a044615..44c46a39 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -359,3 +359,15 @@ fn new_type_snaps_to_smart_guides() { let area = json!({"width": 100.0, "height": 100.0}); assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}"); } + +#[test] +fn layout_cache_follows_fonts_added_later() { + // The web build adds fonts after startup: a caret layout cached before one arrived is stale. + let (d, id, tool) = editing("abc"); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("not text") }; + let before = tool.layout(t); + assert!(Arc::ptr_eq(&before, &tool.layout(t)), "cached while the fonts don't change"); + let font = vectorcraft_text::test_fonts::variable_font().unwrap(); + assert!(FontDb::global().add_font(font) > 0, "the test font is new to this process"); + assert!(!Arc::ptr_eq(&before, &tool.layout(t)), "a font added after the layout must refresh it"); +} diff --git a/docs/development.md b/docs/development.md index e6d1e092..4a30c705 100644 --- a/docs/development.md +++ b/docs/development.md @@ -124,6 +124,23 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese- CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release packages carry each embedded font's `OFL-.txt`. +### Arabic fonts and the web build + +Desktop builds load craft-fonts' Arabic families into the document font database (Noto Sans +Arabic first, the fallback for Arabic text; the others are picked by name). + +The web build doesn't embed them (wasm size). Instead `crates/text/web-fonts.txt` lists the faces +it fetches from beside the wasm, `startup` (before the app starts) or `background` (after): + +- `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL + `fonts//` and the SRI hash, from the craft-fonts manifest). +- `packaging/web/copy-fonts.sh`, a Trunk `post_build` hook, copies the files there. +- `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any + that fail. + +Try it: `cd apps/vectorcraft-web && CRAFT_FONTS_DIR=$PWD/../../../craft-fonts trunk serve`. To serve +another craft-fonts face on the web, add a line to `web-fonts.txt`. + ## Localisation Strings in code stay English and are the default lookup keys. `crates/ui-egui/src/i18n` maps them to display diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess index 7b7e6e18..b798c12a 100644 --- a/packaging/web/.htaccess +++ b/packaging/web/.htaccess @@ -1,13 +1,14 @@ # Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional. AddType application/wasm .wasm +AddType font/ttf .ttf AddType text/javascript .js - AddOutputFilterByType DEFLATE application/wasm text/javascript text/html + AddOutputFilterByType DEFLATE application/wasm text/javascript text/html font/ttf - + Header set Cache-Control "public, max-age=31536000, immutable" diff --git a/packaging/web/README.md b/packaging/web/README.md index bfb81ca3..38a8d620 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -50,6 +50,19 @@ location /vectorcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Fonts folder + +Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) have a `fonts/` folder beside the +wasm: the Arabic fonts the app fetches instead of embedding (about 8 MB, each next to its OFL +licence). Serve it with the rest of the site. Noto Sans Arabic is fetched before the app starts; +the other families load in the background and appear in the font menus as they arrive. + +- Serve `.ttf` as `font/ttf` (the browser checks each file's SHA-256, so a wrong type or a + rewritten file makes the app skip that font and log `web font …` in the console). +- The folder names are the files' SHA-256 prefixes, so `fonts/*` can be cached forever + (`Cache-Control: public, max-age=31536000, immutable`); `_headers` and `.htaccess` do this. +- Without the folder the app still runs, but Arabic text shows as boxes. + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index fac0fbe1..f4f2ac10 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -11,3 +11,7 @@ /index.html Cache-Control: no-cache + +/fonts/* + Content-Type: font/ttf + Cache-Control: public, max-age=31536000, immutable diff --git a/packaging/web/copy-fonts.sh b/packaging/web/copy-fonts.sh new file mode 100755 index 00000000..f62804ba --- /dev/null +++ b/packaging/web/copy-fonts.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Trunk post_build hook (apps/vectorcraft-web/Trunk.toml): copy the craft-fonts faces listed in +# crates/text/web-fonts.txt from $CRAFT_FONTS_DIR into the site, each at +# fonts// with its licence beside it: the URLs +# crates/text/build.rs puts in WEB_FONTS. A no-op without CRAFT_FONTS_DIR. +set -eu +[ -n "${CRAFT_FONTS_DIR:-}" ] || exit 0 +: "${TRUNK_STAGING_DIR:?run me as a Trunk post_build hook (TRUNK_STAGING_DIR unset)}" +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +LIST="$ROOT/crates/text/web-fonts.txt" +MANIFEST="$CRAFT_FONTS_DIR/fonts/manifest.txt" +[ -f "$MANIFEST" ] || { echo "copy-fonts: $MANIFEST not found" >&2; exit 1; } + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +grep -v '^[[:space:]]*#' "$LIST" | grep -v '^[[:space:]]*$' | while IFS= read -r line; do + family=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $2}') + style=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $3}') + row=$(awk -F' [|] ' -v f="$family" -v s="$style" '$1 == f && $2 == s { print $3 "|" $6 "|" $7; exit }' "$MANIFEST") + [ -n "$row" ] || { echo "copy-fonts: $family $style is not in $MANIFEST" >&2; exit 1; } + file=${row%%|*}; rest=${row#*|}; licence=${rest%%|*}; sha=${rest#*|} + got=$(sha256 "$CRAFT_FONTS_DIR/$file") + [ "$got" = "$sha" ] || { echo "copy-fonts: $file: sha256 $got, manifest says $sha" >&2; exit 1; } + dest="$TRUNK_STAGING_DIR/fonts/$(printf '%s' "$sha" | cut -c1-16)" + mkdir -p "$dest" + cp "$CRAFT_FONTS_DIR/$file" "$dest/" + cp "$CRAFT_FONTS_DIR/$licence" "$dest/" +done From 3e20c2cf2958f58076ffaf0ac4f79741685f4e5e Mon Sep 17 00:00:00 2001 From: Ahmed Moussa Date: Fri, 9 Oct 2026 17:18:54 +0300 Subject: [PATCH 02/43] build(web): copy web fonts with cargo xtask, not sh Replace the Trunk post_build hook packaging/web/copy-fonts.sh with `cargo xtask web-fonts` (xtask/src/web_fonts.rs), so the web build only needs trunk, the wasm32 target and cargo, with no POSIX shell, and works the same on Windows. The new task reuses crates/text/build/web_fonts.rs for list parsing and URLs, so build.rs and the copier can't disagree on fonts//. It still does nothing without CRAFT_FONTS_DIR, checks each file's SHA-256 against the manifest, and copies the licence next to each face. A listed face that an older craft-fonts checkout lacks is now skipped with a warning, as build.rs does, instead of failing the build. A relative CRAFT_FONTS_DIR is resolved from the workspace root. Update the references in Trunk.toml, fonts.rs, build.rs, craft_fonts.rs and web-fonts.txt, and document the web build's requirements in docs/development.md. Add tests for the copy, a hash mismatch and the no-op case. Signed-off-by: Ahmed Moussa --- apps/vectorcraft-web/Trunk.toml | 7 +- apps/vectorcraft-web/src/fonts.rs | 2 +- crates/text/build.rs | 6 +- crates/text/build/web_fonts.rs | 2 +- crates/text/src/craft_fonts.rs | 2 +- crates/text/web-fonts.txt | 2 +- docs/development.md | 11 ++- packaging/web/copy-fonts.sh | 31 -------- xtask/src/main.rs | 4 + xtask/src/web_fonts.rs | 119 ++++++++++++++++++++++++++++++ 10 files changed, 144 insertions(+), 42 deletions(-) delete mode 100755 packaging/web/copy-fonts.sh create mode 100644 xtask/src/web_fonts.rs diff --git a/apps/vectorcraft-web/Trunk.toml b/apps/vectorcraft-web/Trunk.toml index f468dc13..aad9a163 100644 --- a/apps/vectorcraft-web/Trunk.toml +++ b/apps/vectorcraft-web/Trunk.toml @@ -10,8 +10,9 @@ addresses = ["127.0.0.1"] port = 8766 # Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/ -# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`. +# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so +# it needs no shell (xtask/src/web_fonts.rs). [[hooks]] stage = "post_build" -command = "sh" -command_arguments = ["../../packaging/web/copy-fonts.sh"] +command = "cargo" +command_arguments = ["xtask", "web-fonts"] diff --git a/apps/vectorcraft-web/src/fonts.rs b/apps/vectorcraft-web/src/fonts.rs index 36c7a380..2677c11e 100644 --- a/apps/vectorcraft-web/src/fonts.rs +++ b/apps/vectorcraft-web/src/fonts.rs @@ -1,5 +1,5 @@ //! Craft-fonts faces served beside the wasm (`fonts//…`, copied by -//! `packaging/web/copy-fonts.sh`) instead of embedded: [`load_startup_fonts`] fetches the `startup` +//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup` //! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks //! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped: //! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing. diff --git a/crates/text/build.rs b/crates/text/build.rs index aed93aa3..73c952fa 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -9,9 +9,9 @@ //! limits (Cloudflare Pages: 25 MiB). //! //! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from -//! `fonts//` beside the wasm instead of embedding (`packaging/web/copy-fonts.sh` copies them -//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is a -//! warning, or an error with `CRAFT_FONTS_REQUIRED=1`, and never costs the desktop its fonts. +//! `fonts//` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them +//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left +//! out with a warning, never an error, and never costs the desktop its fonts. use std::fmt::Write as _; use std::path::{Path, PathBuf}; diff --git a/crates/text/build/web_fonts.rs b/crates/text/build/web_fonts.rs index 94dcddb5..a2e2a805 100644 --- a/crates/text/build/web_fonts.rs +++ b/crates/text/build/web_fonts.rs @@ -1,6 +1,6 @@ //! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the //! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included -//! by both, so it has no dependencies). `packaging/web/copy-fonts.sh` must agree with [`url`]. +//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`]. /// One line of `web-fonts.txt`: `when | family | style`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs index 715a73ed..66887b91 100644 --- a/crates/text/src/craft_fonts.rs +++ b/crates/text/src/craft_fonts.rs @@ -16,7 +16,7 @@ pub struct CraftFont { /// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding, /// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by -/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `packaging/web/copy-fonts.sh`. +/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`. pub struct WebFont { pub family: &'static str, pub style: &'static str, diff --git a/crates/text/web-fonts.txt b/crates/text/web-fonts.txt index 5cd21e94..1c8c4401 100644 --- a/crates/text/web-fonts.txt +++ b/crates/text/web-fonts.txt @@ -2,7 +2,7 @@ # One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt). # startup = fetched before the app starts (fallbacks the first frame needs) # background = fetched after the app starts (fonts users pick by name) -# Read by build.rs (the WEB_FONTS table) and packaging/web/copy-fonts.sh (copies the files). +# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook). startup | Noto Sans Arabic | Regular background | Cairo | Regular background | Tajawal | ExtraLight diff --git a/docs/development.md b/docs/development.md index 4a30c705..1f28f924 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,6 +12,13 @@ trunk build --release # writes ../../dist/web (index.html, .js glue trunk serve --release # dev server on http://127.0.0.1:8766 ``` +What the web build needs: `trunk`, the `wasm32-unknown-unknown` target and `cargo`, nothing else (no +POSIX shell, so it works the same on Windows). Optionally, `CRAFT_FONTS_DIR` set to an absolute +craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's `post_build` hook runs +`cargo xtask web-fonts`, which copies them into `dist/web/fonts/` (it does nothing without +`CRAFT_FONTS_DIR`, and skips with a warning any listed face an older checkout lacks). See +[Arabic fonts and the web build](#arabic-fonts-and-the-web-build). + Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression. URL flag: `?webgl` forces the WebGL2 backend. @@ -134,7 +141,9 @@ it fetches from beside the wasm, `startup` (before the app starts) or `backgroun - `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL `fonts//` and the SRI hash, from the craft-fonts manifest). -- `packaging/web/copy-fonts.sh`, a Trunk `post_build` hook, copies the files there. +- `cargo xtask web-fonts` (`xtask/src/web_fonts.rs`), the Trunk `post_build` hook, copies the files + there, checking each one's SHA-256. A listed face the checkout lacks is skipped with a warning, as + in `build.rs`, so an older craft-fonts just means fewer fonts. - `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any that fail. diff --git a/packaging/web/copy-fonts.sh b/packaging/web/copy-fonts.sh deleted file mode 100755 index f62804ba..00000000 --- a/packaging/web/copy-fonts.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -# Trunk post_build hook (apps/vectorcraft-web/Trunk.toml): copy the craft-fonts faces listed in -# crates/text/web-fonts.txt from $CRAFT_FONTS_DIR into the site, each at -# fonts// with its licence beside it: the URLs -# crates/text/build.rs puts in WEB_FONTS. A no-op without CRAFT_FONTS_DIR. -set -eu -[ -n "${CRAFT_FONTS_DIR:-}" ] || exit 0 -: "${TRUNK_STAGING_DIR:?run me as a Trunk post_build hook (TRUNK_STAGING_DIR unset)}" -ROOT=$(cd "$(dirname "$0")/../.." && pwd) -LIST="$ROOT/crates/text/web-fonts.txt" -MANIFEST="$CRAFT_FONTS_DIR/fonts/manifest.txt" -[ -f "$MANIFEST" ] || { echo "copy-fonts: $MANIFEST not found" >&2; exit 1; } - -sha256() { - if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 - else shasum -a 256 "$1" | cut -d' ' -f1; fi -} - -grep -v '^[[:space:]]*#' "$LIST" | grep -v '^[[:space:]]*$' | while IFS= read -r line; do - family=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $2}') - style=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $3}') - row=$(awk -F' [|] ' -v f="$family" -v s="$style" '$1 == f && $2 == s { print $3 "|" $6 "|" $7; exit }' "$MANIFEST") - [ -n "$row" ] || { echo "copy-fonts: $family $style is not in $MANIFEST" >&2; exit 1; } - file=${row%%|*}; rest=${row#*|}; licence=${rest%%|*}; sha=${rest#*|} - got=$(sha256 "$CRAFT_FONTS_DIR/$file") - [ "$got" = "$sha" ] || { echo "copy-fonts: $file: sha256 $got, manifest says $sha" >&2; exit 1; } - dest="$TRUNK_STAGING_DIR/fonts/$(printf '%s' "$sha" | cut -c1-16)" - mkdir -p "$dest" - cp "$CRAFT_FONTS_DIR/$file" "$dest/" - cp "$CRAFT_FONTS_DIR/$licence" "$dest/" -done diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cc568adf..16631b06 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -12,6 +12,7 @@ mod layers; mod sha256; mod stats; mod version; +mod web_fonts; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -32,6 +33,8 @@ commands: ico ... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk + post_build hook; a no-op without CRAFT_FONTS_DIR) "; fn main() -> ExitCode { @@ -49,6 +52,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) diff --git a/xtask/src/web_fonts.rs b/xtask/src/web_fonts.rs new file mode 100644 index 00000000..42943108 --- /dev/null +++ b/xtask/src/web_fonts.rs @@ -0,0 +1,119 @@ +//! `cargo xtask web-fonts`: the Trunk `post_build` hook (`apps/*-web/Trunk.toml`). Copies the +//! craft-fonts faces listed in `crates/text/web-fonts.txt` from `$CRAFT_FONTS_DIR` into the site, +//! each at `fonts//` with its licence beside it: the URLs +//! `crates/text/build.rs` puts in `WEB_FONTS` (both use `crates/text/build/web_fonts.rs`). +//! +//! A no-op without `CRAFT_FONTS_DIR`. A listed face the checkout lacks (an older craft-fonts) is +//! skipped with a warning, as `build.rs` does; a file whose SHA-256 doesn't match the manifest is an +//! error (a corrupt checkout). Rust rather than a shell script, so the hook runs wherever cargo does. + +use std::path::{Path, PathBuf}; + +use crate::sha256; + +#[allow(dead_code)] // build.rs uses the rest (sri, base64) +#[path = "../../crates/text/build/web_fonts.rs"] +mod list; + +/// The hook: reads `CRAFT_FONTS_DIR` and Trunk's `TRUNK_STAGING_DIR`. +pub fn run(root: &Path) -> Result<(), String> { + let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from); + run_with(root, var("CRAFT_FONTS_DIR"), var("TRUNK_STAGING_DIR")) +} + +fn run_with(root: &Path, craft_fonts: Option, staging: Option) -> Result<(), String> { + let Some(dir) = craft_fonts else { return Ok(()) }; + // Relative like build.rs: from the workspace root, not the directory Trunk runs hooks in. + let dir = if dir.is_absolute() { dir } else { root.join(dir) }; + let staging = staging.ok_or("TRUNK_STAGING_DIR is unset: run this as the Trunk post_build hook")?; + let (copied, skipped) = copy(&root.join("crates/text/web-fonts.txt"), &dir, &staging)?; + if !skipped.is_empty() { + eprintln!("warning: web build won't fetch {} (not in CRAFT_FONTS_DIR={}; an older craft-fonts?)", skipped.join(", "), dir.display()); + } + println!("web-fonts: copied {copied} font(s) into {}", staging.join("fonts").display()); + Ok(()) +} + +/// Copy the faces `list_path` names from the craft-fonts checkout `dir` into `site` (at their +/// `fonts/…` URLs). Returns how many were copied and the listed faces the checkout lacks. +fn copy(list_path: &Path, dir: &Path, site: &Path) -> Result<(usize, Vec), String> { + let read = |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display())); + let list = read(list_path)?; + let manifest = read(&dir.join("fonts/manifest.txt"))?; + let rows: Vec> = + manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect(); + let (mut copied, mut skipped) = (0, Vec::new()); + for e in list::parse_list(&list)? { + let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else { + skipped.push(format!("{} {}", e.family, e.style)); + continue; + }; + let (Some(file), Some(licence), Some(sha)) = (row.get(2), row.get(5), row.get(6)) else { + return Err(format!("malformed manifest row for {} {}", e.family, e.style)); + }; + let bytes = std::fs::read(dir.join(file)).map_err(|err| format!("{file}: {err}"))?; + let got = sha256::hex(&bytes); + if got != *sha { + return Err(format!("{file}: sha256 {got}, manifest says {sha}")); + } + let dest = site.join(list::url(sha, file)); + let folder = dest.parent().ok_or_else(|| format!("{}: no parent folder", dest.display()))?; + std::fs::create_dir_all(folder).map_err(|err| format!("{}: {err}", folder.display()))?; + std::fs::write(&dest, &bytes).map_err(|err| format!("{}: {err}", dest.display()))?; + let licence_name = Path::new(licence).file_name().ok_or_else(|| format!("{licence}: not a file"))?; + std::fs::copy(dir.join(licence), folder.join(licence_name)).map_err(|err| format!("{licence}: {err}"))?; + copied += 1; + } + Ok((copied, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fake craft-fonts checkout with one face, and a list naming it plus one it lacks. + fn fixture(name: &str, manifest_sha: Option<&str>) -> (PathBuf, PathBuf, PathBuf, String) { + let base = std::env::temp_dir().join(format!("xtask-web-fonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("craft-fonts"); + std::fs::create_dir_all(dir.join("fonts/a")).unwrap(); + std::fs::write(dir.join("fonts/a/A-Regular.ttf"), b"font bytes").unwrap(); + std::fs::write(dir.join("fonts/a/OFL.txt"), b"licence").unwrap(); + let sha = sha256::hex(b"font bytes"); + let row_sha = manifest_sha.map_or(sha.clone(), str::to_string); + std::fs::write( + dir.join("fonts/manifest.txt"), + format!("# family | style | file | scripts | licence | licence file | sha256 | source\nA Sans | Regular | fonts/a/A-Regular.ttf | Arab | OFL-1.1 | fonts/a/OFL.txt | {row_sha} | https://example.invalid\n"), + ) + .unwrap(); + let list = base.join("web-fonts.txt"); + std::fs::write(&list, "startup | A Sans | Regular\nbackground | Missing Sans | Bold\n").unwrap(); + (list, dir, base.join("site"), sha) + } + + #[test] + fn copies_listed_faces_with_their_licence_to_their_urls() { + let (list, dir, site, sha) = fixture("copy", None); + let (copied, skipped) = copy(&list, &dir, &site).unwrap(); + assert_eq!(copied, 1); + let at = site.join("fonts").join(&sha[..16]); + assert_eq!(std::fs::read(at.join("A-Regular.ttf")).unwrap(), b"font bytes"); + assert_eq!(std::fs::read(at.join("OFL.txt")).unwrap(), b"licence"); + assert_eq!(format!("fonts/{}/A-Regular.ttf", &sha[..16]), list::url(&sha, "fonts/a/A-Regular.ttf")); + assert_eq!(skipped, ["Missing Sans Bold"], "a face the checkout lacks is skipped, not an error"); + } + + #[test] + fn a_file_that_does_not_match_its_manifest_hash_is_an_error() { + let (list, dir, site, _) = fixture("mismatch", Some(&"0".repeat(64))); + let e = copy(&list, &dir, &site).unwrap_err(); + assert!(e.contains("A-Regular.ttf") && e.contains("sha256"), "{e}"); + } + + #[test] + fn without_craft_fonts_the_hook_does_nothing() { + let (_, _, site, _) = fixture("noop", None); + run_with(Path::new("/nonexistent"), None, None).unwrap(); + assert!(!site.exists()); + } +} From 75151bd79a8a074f8315687e68772f69e3badb3c Mon Sep 17 00:00:00 2001 From: Matthew Farrow Date: Fri, 9 Oct 2026 10:43:23 -0400 Subject: [PATCH 03/43] Clippy 1.99: use as_chunks for constant-size chunks, and say the gate targets the latest stable Clippy 1.99.0 added `chunks_exact_to_as_chunks`, which fails `cargo xtask ci` at its clippy step. Eight uses of `chunks_exact(_mut)` with a constant size now use `as_chunks(_mut)` (stable since 1.88, within the 1.95 minimum): four in crates/affinity, three in crates/effects and crates/engine tests, and two in xtask's sha256. docs/development.md says the gate targets the current stable. Co-Authored-By: Claude Sonnet 5.5 --- crates/affinity/src/container.rs | 4 ++-- crates/affinity/src/paint.rs | 2 +- crates/affinity/src/raster.rs | 2 +- crates/effects/src/pixel/tests.rs | 6 +++--- crates/engine/tests/affinity_corpus.rs | 2 +- docs/development.md | 4 +++- xtask/src/sha256.rs | 6 +++--- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 0829f858..31179a3a 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -337,7 +337,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { (0x40, _) => byte_delta(b), (0x80, false) => { let mut prev = 0u16; - for pair in b.chunks_exact_mut(2) { + for pair in b.as_chunks_mut::<2>().0.iter_mut() { let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev); pair.copy_from_slice(&v.to_le_bytes()); prev = v; @@ -349,7 +349,7 @@ fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { if b.len() == 0x10000 { let planes = b.to_vec(); let (hi, lo) = planes.split_at(0x8000); - for (i, out) in b.chunks_exact_mut(4).enumerate() { + for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else { return Err(Error::Malformed("tile size")); }; diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 7f08fa51..c4ebff84 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -129,7 +129,7 @@ const MAX_STOPS: usize = 1024; fn floats(v: &Value) -> Option> { match v { Value::Floats(f) => Some(f.clone()), - Value::Bytes(b) if b.len() % 4 == 0 => Some(b.chunks_exact(4).map(|c| f64::from(f32::from_le_bytes([c[0], c[1], c[2], c[3]]))).collect()), + Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), _ => None, } } diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index f89aa262..8cdf9b83 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -131,7 +131,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 } }; - for (i, px) in rgba.chunks_exact_mut(4).enumerate() { + for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index ff27132e..dc3d1d04 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -116,7 +116,7 @@ fn every_effect_is_deterministic() { assert_eq!(a, b, "{id}"); assert_ne!(a, src, "{id} changes the image"); // Premultiplied stays premultiplied. - assert!(a.chunks_exact(4).all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}"); + assert!(a.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]), "{id}"); } } @@ -196,9 +196,9 @@ fn lengths_are_document_units() { fn gaussian_and_plane_blurs_keep_their_mass() { let (w, h) = (30, 30); let mut d = image(w, h, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { grey(255) } else { [0, 0, 0, 0] }); - let before: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum(); + let before: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum(); gaussian_rgba(&mut d, w, h, 2.0); - let after: u32 = d.chunks_exact(4).map(|p| p[3] as u32).sum(); + let after: u32 = d.as_chunks::<4>().0.iter().map(|p| p[3] as u32).sum(); assert!((before as f64 - after as f64).abs() / (before as f64) < 0.02, "{before} → {after}"); assert!(at(&d, w, 9, 15)[3] > 0 && at(&d, w, 10, 15)[3] < 255); let mut plane: Vec = (0..w * h).map(|i| if i == 15 * w + 15 { 100.0 } else { 0.0 }).collect(); diff --git a/crates/engine/tests/affinity_corpus.rs b/crates/engine/tests/affinity_corpus.rs index 2be10b94..3116088f 100644 --- a/crates/engine/tests/affinity_corpus.rs +++ b/crates/engine/tests/affinity_corpus.rs @@ -48,7 +48,7 @@ fn difference(a: &[u8], b: &[u8]) -> f64 { [0, 1, 2].map(|i| f64::from(p[i]) * al + 255.0 * (1.0 - al)) }; let (mut sum, mut n) = (0.0, 0usize); - for (p, q) in a.chunks_exact(4).zip(b.chunks_exact(4)) { + for (p, q) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0.iter()) { let (x, y) = (white(p), white(q)); sum += (0..3).map(|i| (x[i] - y[i]).abs()).sum::(); n += 3; diff --git a/docs/development.md b/docs/development.md index e6d1e092..6e3806f5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -194,7 +194,9 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with -`-D warnings`: +`-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the +minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed +in the code: | Banned in shipped code | Use instead | |---|---| diff --git a/xtask/src/sha256.rs b/xtask/src/sha256.rs index ed731dfb..71cfa396 100644 --- a/xtask/src/sha256.rs +++ b/xtask/src/sha256.rs @@ -19,10 +19,10 @@ pub fn hex(data: &[u8]) -> String { msg.push(0); } msg.extend(bits.to_be_bytes()); - for block in msg.chunks_exact(64) { + for block in msg.as_chunks::<64>().0.iter() { let mut w = [0u32; 64]; - for (i, word) in block.chunks_exact(4).enumerate() { - w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + for (i, word) in block.as_chunks::<4>().0.iter().enumerate() { + w[i] = u32::from_be_bytes(*word); } for i in 16..64 { let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); From a61d058f1b9729ef6643dc55b81a6e100579af7a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 06:54:39 -0700 Subject: [PATCH 04/43] Swatches: Save Swatch Library writes swatch exchange (.ase) files The Save Swatch Library dialog lists Swatch Exchange (.ase) as a format, and swatch.library.save takes format: "ase" or a path ending in .ase. Each solid color keeps its model (RGB, CMYK, Lab or Gray) and its type (spot, global or process), and color groups stay groups. Gradients and patterns are left out, and a tint swatch is written as the color it shows. Without a path or user: true, the command returns the file as dataBase64. A swatch exchange library opens under the name of its file. When Save Swatch Library or Save Graphic Style Library saves to a file, the save panel suggests the Name from the dialog as the file name, as a save to User Defined does, and the web app downloads the file under that name. A name made only of dots and spaces, such as "...", gives the file name Library (Library.vcswatches) in place of a hidden file. palette_io::write returns the file's bytes. A property test writes generated libraries and reads them back, and an import fuzz test loads mutated copies of a file the writer wrote. --- ROADMAP.md | 2 +- crates/color/src/palette_io.rs | 324 +++++++++++++++++- crates/engine/src/cmd/stylelib.rs | 4 +- crates/engine/src/cmd/swatchlib.rs | 50 ++- crates/engine/src/tests_swatchlib.rs | 48 +++ crates/engine/tests/import_fuzz.rs | 27 ++ .../src/dialogs/save_swatch_library.rs | 26 +- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/io.rs | 32 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 41 +++ docs/control-protocol.md | 7 +- docs/mcp.md | 9 +- 21 files changed, 530 insertions(+), 51 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 19549596..84ebbe1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -151,7 +151,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 1bdfc9fb..43581481 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -1,7 +1,7 @@ //! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients //! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment -//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read -//! only ([`read_bytes`]). +//! headers), swatch exchange `.ase` files (binary: solid colors in their own model, global, spot +//! and process colors, color groups) and CSS custom properties (written only). use std::collections::{HashMap, HashSet}; @@ -16,18 +16,22 @@ pub enum PaletteFormat { Native, /// `.gpl`: a plain-text RGB palette many paint and design tools read. Gpl, + /// `.ase`: a swatch exchange file (binary): solid colors in their own model, global, spot or + /// process, and color groups. + Ase, /// `.css`: custom properties on `:root` (colours and gradients; written only). Css, } impl PaletteFormat { - pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css]; + pub const ALL: [PaletteFormat; 4] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase, PaletteFormat::Css]; /// The format's id, which is also its extension. pub fn id(self) -> &'static str { match self { PaletteFormat::Native => "vcswatches", PaletteFormat::Gpl => "gpl", + PaletteFormat::Ase => "ase", PaletteFormat::Css => "css", } } @@ -35,6 +39,7 @@ impl PaletteFormat { match self { PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)", PaletteFormat::Gpl => "GPL Palette (.gpl)", + PaletteFormat::Ase => "Swatch Exchange (.ase)", PaletteFormat::Css => "CSS Custom Properties (.css)", } } @@ -47,6 +52,10 @@ impl PaletteFormat { pub fn readable(self) -> bool { self != PaletteFormat::Css } + /// Are files of this format binary rather than text? + pub fn binary(self) -> bool { + self == PaletteFormat::Ase + } } /// The native file: a header around the library. @@ -71,11 +80,13 @@ const ASE_MAX_ENTRIES: usize = 100_000; const ASE_MAX_NAME: usize = 1024; const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short"; -/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left -/// out; `.gpl` keeps solid colours only, as 8-bit RGB. -pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String { +/// Write `lib` in `format` → the file's bytes. Pattern swatches (their tiles live in a document) +/// and None are left out; `.gpl` keeps solid colours only, as 8-bit RGB, and `.ase` keeps solid +/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than +/// [`read_bytes`] reads from one file. +pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result, String> { let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_)); - match format { + let text = match format { PaletteFormat::Native => { let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::>(); let library = SwatchLibrary { @@ -87,8 +98,10 @@ pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String { serde_json::to_string_pretty(&file).unwrap_or_default() } PaletteFormat::Gpl => write_gpl(lib), + PaletteFormat::Ase => return write_ase(lib), PaletteFormat::Css => write_css(lib, keep), - } + }; + Ok(text.into_bytes()) } /// One line of text: no line breaks or tabs. @@ -184,6 +197,102 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { out } +/// `.ase`, as [`read_ase`] reads it: version 1.0, the ungrouped colors, then each color group as a +/// group start (its name), its colors and a group end, also for a group without colors. A color is +/// written in its own model: RGB, CMYK and gray components from 0 to 1 (gray as a level, 1 is +/// white), Lab lightness as a fraction of 100 and a and b from −128 to 127. Components are clamped +/// to those ranges, and a component that isn't a finite number is written as 0. The color type is +/// 1 for a spot color, 0 for another global color and 2 for a process color. +/// +/// A swatch exchange file holds solid colors only: gradients, patterns and None are left out, and +/// a tint swatch is written as the color it shows, a process color without its link to its base. +/// The file has no library name ([`read_ase`] names a library after its file). More than +/// [`ASE_MAX_ENTRIES`] colors and groups is an error. +fn write_ase(lib: &SwatchLibrary) -> Result, String> { + let solid = |list: &[Swatch]| list.iter().filter(|w| w.paint.color().is_some()).count(); + let entries = solid(&lib.swatches) + lib.groups.iter().map(|g| 1 + solid(&g.swatches)).sum::(); + if entries > ASE_MAX_ENTRIES { + return Err(format!( + "VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries}" + )); + } + let mut body = vec![]; + let mut blocks = put_ase_colors(&mut body, &lib.swatches)?; + for g in &lib.groups { + let mut name = vec![]; + put_ase_name(&mut name, &g.name)?; + put_ase_block(&mut body, ASE_GROUP_START, &name)?; + blocks += 2 + put_ase_colors(&mut body, &g.swatches)?; + put_ase_block(&mut body, ASE_GROUP_END, &[])?; + } + let blocks = u32::try_from(blocks).map_err(|_| "too many swatch exchange blocks".to_string())?; + let mut out = ASE_SIGNATURE.to_vec(); + out.extend(1u16.to_be_bytes()); + out.extend(0u16.to_be_bytes()); + out.extend(blocks.to_be_bytes()); + out.extend(body); + Ok(out) +} + +/// The color blocks of the solid colors in `list` → how many were written. +fn put_ase_colors(out: &mut Vec, list: &[Swatch]) -> Result { + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + let ab = |v: f32| if v.is_finite() { v.clamp(-128.0, 127.0) } else { 0.0 }; + let mut n = 0; + for (w, color) in list.iter().filter_map(|w| Some((w, w.paint.color()?))) { + let mut body = vec![]; + put_ase_name(&mut body, &w.name)?; + let (model, values): (&[u8; 4], Vec) = match color { + Color::Rgb { r, g, b } => (b"RGB ", vec![unit(r), unit(g), unit(b)]), + Color::Cmyk { c, m, y, k } => (b"CMYK", vec![unit(c), unit(m), unit(y), unit(k)]), + Color::Lab { l, a, b } => (b"LAB ", vec![unit(l / 100.0), ab(a), ab(b)]), + Color::Gray { k } => (b"Gray", vec![1.0 - unit(k)]), + }; + body.extend(model); + body.extend(values.iter().flat_map(|v| v.to_be_bytes())); + let kind: u16 = if w.spot { + 1 + } else if w.global { + 0 + } else { + 2 + }; + body.extend(kind.to_be_bytes()); + put_ase_block(out, ASE_COLOR, &body)?; + n += 1; + } + Ok(n) +} + +/// A block: its type, the length of its body and the body. +fn put_ase_block(out: &mut Vec, kind: u16, body: &[u8]) -> Result<(), String> { + let len = u32::try_from(body.len()).map_err(|_| "a swatch exchange block is too long".to_string())?; + out.extend(kind.to_be_bytes()); + out.extend(len.to_be_bytes()); + out.extend_from_slice(body); + Ok(()) +} + +/// A name as `.ase` stores it: a `u16` count of UTF-16 code units, the terminating zero included, +/// then the units. NUL characters are left out, and a name longer than [`ASE_MAX_NAME`] code units +/// is cut before the character that would pass that limit. +fn put_ase_name(out: &mut Vec, name: &str) -> Result<(), String> { + let mut units: Vec = vec![]; + for c in name.chars().filter(|&c| c != '\0') { + let mut buf = [0; 2]; + let encoded = c.encode_utf16(&mut buf); + if units.len() + encoded.len() > ASE_MAX_NAME { + break; + } + units.extend_from_slice(encoded); + } + units.push(0); + let count = u16::try_from(units.len()).map_err(|_| "a swatch exchange name is too long".to_string())?; + out.extend(count.to_be_bytes()); + out.extend(units.iter().flat_map(|u| u.to_be_bytes())); + Ok(()) +} + /// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the /// file doesn't. pub fn read(text: &str, name: &str) -> Result { @@ -442,6 +551,7 @@ impl UniqueNames { mod tests { use super::*; use crate::{Gradient, GradientPaint, GradientStop}; + use proptest::prelude::*; fn sample() -> SwatchLibrary { let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { @@ -468,10 +578,15 @@ mod tests { } } + /// `lib` written as text format `f`. + fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String { + String::from_utf8(write(lib, f).unwrap()).unwrap() + } + #[test] fn native_round_trip_keeps_cmyk_spot_and_groups() { let lib = sample(); - let text = write(&lib, PaletteFormat::Native); + let text = written(&lib, PaletteFormat::Native); assert!(sniff(&text)); let back = read(&text, "fallback").unwrap(); let mut expected = lib.clone(); @@ -484,7 +599,7 @@ mod tests { #[test] fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() { - let text = write(&sample(), PaletteFormat::Gpl); + let text = written(&sample(), PaletteFormat::Gpl); assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n")); assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}"); assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours"); @@ -514,7 +629,7 @@ mod tests { assert_eq!(css_property("#FF00CC"), "--\\#ff00cc"); assert_eq!(css_property("Café 50%"), "--café-50\\%"); assert_eq!(css_property(" "), "--swatch"); - let text = write(&sample(), PaletteFormat::Css); + let text = written(&sample(), PaletteFormat::Css); assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}"); assert!(text.contains(" --ink: #"), "{text}"); assert!(text.contains("; /* spot */")); @@ -528,7 +643,7 @@ mod tests { swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(), groups: vec![], }; - let text = write(&lib, PaletteFormat::Css); + let text = written(&lib, PaletteFormat::Css); assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); assert!(read(&text, "x").is_err(), "CSS is written only"); } @@ -645,4 +760,189 @@ mod tests { assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than")); assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit"); } + + #[test] + fn ase_writes_each_model_kind_and_group() { + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let tint = Swatch { + name: "Ink 40%".into(), + paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 }, + global: false, + spot: false, + }; + // `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals. + let mut lib = sample(); + lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false)); + lib.swatches.push(tint); + lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)]; + lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] }); + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2), + (ASE_GROUP_START, ase_name("Neutrals")), + ase_color("Mist", b"Gray", &[0.75], 2), + ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("Empty")), + (ASE_GROUP_END, vec![]), + ]); + assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows"); + let back = read_bytes(&bytes, "Brand").unwrap(); + assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays"); + assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase)); + assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary()); + } + + #[test] + fn ase_names_lose_nul_and_stop_at_the_reader_limit() { + let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))]; + let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect(); + let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect(); + // At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji. + assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]); + } + + #[test] + fn ase_clamps_components_and_writes_non_finite_ones_as_zero() { + let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }; + let swatches = vec![ + sw("Lab", Color::lab(100.5, 300.0, -300.0)), + sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)), + sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)), + sw("Gray", Color::gray(1.5)), + ]; + let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap(); + let expected = ase(&[ + ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2), + ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2), + ase_color("Gray", b"Gray", &[0.0], 2), + ]); + assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0"); + // Lightness 100.5 is written as 1, which reads back as L* 100. + let back = read_bytes(&bytes, "x").unwrap(); + assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0))); + } + + #[test] + fn ase_write_refuses_more_entries_than_the_reader_takes() { + let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false }; + // The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it. + let mut lib = SwatchLibrary { + name: "x".into(), + swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(), + groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }], + }; + let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap(); + assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1)); + lib.swatches.push(gray(0)); + let e = write(&lib, PaletteFormat::Ase).unwrap_err(); + assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}"); + // A gradient is left out and is not counted. + lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone(); + assert!(write(&lib, PaletteFormat::Ase).is_ok()); + } + + /// A color in one of the four models, in the ranges `.ase` holds. + fn arb_color() -> impl Strategy { + let unit = || 0.0f32..=1.0; + prop_oneof![ + (unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)), + (unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)), + unit().prop_map(Color::gray), + (0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)), + ] + } + + /// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch + /// (both flags off) or a gradient. + fn arb_paint() -> impl Strategy { + prop_oneof![ + 4 => (arb_color(), any::(), any::()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)), + 1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)), + 1 => Just((sample().swatches[1].paint.clone(), false, false)), + ] + } + + /// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script + /// but NUL, trimmed, so swatches and groups have unique names, as in a document. + fn arb_library() -> impl Strategy { + let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12); + (swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| { + let mut n = 0; + let mut name = |prefix: &str, base: &str| { + n += 1; + format!("{prefix}{n} {base}").trim().to_string() + }; + let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() }; + for ((paint, global, spot), base) in loose { + lib.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + for (base, list) in groups { + let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] }; + for ((paint, global, spot), base) in list { + g.swatches.push(Swatch { name: name("", &base), paint, global, spot }); + } + lib.groups.push(g); + } + lib + }) + } + + /// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and + /// gray read back within `f32` rounding. + fn close(got: Color, want: Color) -> bool { + let near = |a: f32, b: f32| (a - b).abs() < 1e-4; + match (got, want) { + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2), + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2), + (Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2), + (Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2), + _ => false, + } + } + + /// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot + /// colors global. + fn ase_expected(list: &[Swatch]) -> Vec { + list.iter() + .filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot })) + .collect() + } + + /// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`? + fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool { + got.len() == want.len() + && got.iter().zip(want).all(|(g, w)| { + let color = match (&g.paint, w.paint.color()) { + (Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c), + _ => false, + }; + g.name == w.name && g.global == w.global && g.spot == w.spot && color + }) + } + + proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + + /// A written `.ase` file reads back as the library's solid colors in their own model, with + /// their names, kinds and color groups. + #[test] + fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) { + let bytes = write(&lib, PaletteFormat::Ase).unwrap(); + prop_assert!(sniff_bytes(&bytes)); + let back = read_bytes(&bytes, &lib.name).unwrap(); + prop_assert_eq!(&back.name, &lib.name); + prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches); + prop_assert_eq!(back.groups.len(), lib.groups.len()); + for (g, want) in back.groups.iter().zip(&lib.groups) { + prop_assert_eq!(&g.name, &want.name); + prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches); + } + } + } } diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 99459e7a..ab5c791d 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -15,7 +15,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary}; use super::menucmds::squash; use super::swatch::str_list; -use super::swatchlib::{LibraryFile, LibraryInfo, stem}; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; use super::*; pub fn specs() -> Vec { @@ -211,7 +211,7 @@ fn save(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; let mut out = json!({ "count": lib.len() }); - s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?; + s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?; Ok(out) } diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 54a49898..016c0fd2 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "Save Swatch Library…", ["Window", "Swatches"], None, - "{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}", + "{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase", has_doc, save ), @@ -117,6 +117,14 @@ impl LibraryFile for SwatchLibrary { /// The extensions of library files [`palette_io::read_bytes`] reads. pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"]; +/// A library file the save commands write ([`Libraries::write`]). +pub(crate) enum FileData { + /// Text (`.vcswatches`, `.gpl`, CSS, `.vcstyles`), returned as `data`. + Text(String), + /// Bytes (`.ase`), returned as `dataBase64`. + Binary(Vec), +} + impl Libraries { pub fn user_dir(&self) -> Option<&str> { self.user_dir.as_deref() @@ -155,29 +163,34 @@ impl Libraries { self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref()) } - /// Write library file `text` as the save commands do: into the user library folder as + /// Write library file `data` as the save commands do: into the user library folder as /// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as - /// `data`; the results go into `out`. - pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> { + /// `data` (text) or `dataBase64` (bytes); the results go into `out`. + pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, data: FileData, out: &mut Value, cmd: &str) -> Result<()> { + let bytes = match &data { + FileData::Text(text) => text.as_bytes(), + FileData::Binary(bytes) => bytes.as_slice(), + }; if bool_or(p, "user", false) { let dir = self .user_dir() .ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))? .to_string(); - // A file name from the library's name, without characters file systems reject. - let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); - let file = format!("{}.{ext}", base.trim_matches(['.', ' '])); + let file = library_file_name(name, ext); let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); create_dir(&dir)?; - write_file(&path, text.as_bytes())?; + write_file(&path, bytes)?; self.rescan(); out["path"] = json!(path); out["library"] = json!(format!("user/{file}")); } else if let Some(path) = str_param(p, "path") { - write_file(path, text.as_bytes())?; + write_file(path, bytes)?; out["path"] = json!(path); } else { - out["data"] = json!(text); + match data { + FileData::Text(text) => out["data"] = json!(text), + FileData::Binary(bytes) => out["dataBase64"] = json!(vectorcraft_format::base64_encode(&bytes)), + } } Ok(()) } @@ -207,6 +220,14 @@ impl Libraries { } } +/// The file name of library `name` with extension `ext`: characters file systems reject become +/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`. +pub fn library_file_name(name: &str, ext: &str) -> String { + let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect(); + let base = base.trim_matches(['.', ' ']); + format!("{}.{ext}", if base.is_empty() { "Library" } else { base }) +} + /// The library files with extensions `exts` in folder `dir`, sorted by name. #[cfg(not(target_arch = "wasm32"))] fn library_files(dir: &str, exts: &[&str]) -> Vec { @@ -343,15 +364,16 @@ fn save(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.library.save"; let path = str_param(p, "path"); let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) { - Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?, + Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?, None => PaletteFormat::Native, }; let st = s.doc()?; let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?; - let (count, text) = (lib.len(), palette_io::write(&lib, format)); - let mut out = json!({"format": format.id(), "count": count}); - s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?; + let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?; + let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) }; + let mut out = json!({"format": format.id(), "count": lib.len()}); + s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?; Ok(out) } diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index ccdcd2d3..cde437f8 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -182,6 +182,10 @@ fn libraries_saved_in_the_user_folder_are_user_defined() { let css = dir.join("out.css").to_string_lossy().to_string(); run(&mut s, "swatch.library.save", json!({ "path": css })); assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;")); + // A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it. + let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."})); + assert_eq!(r["library"], "user/Library.vcswatches"); + assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into())); let _ = std::fs::remove_dir_all(dir); } @@ -235,6 +239,50 @@ fn swatch_exchange_files_load_add_and_list_as_user_libraries() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn swatch_exchange_files_save_with_models_kinds_and_groups() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true})); + run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}})); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]})); + let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"})); + assert_eq!(r["format"], "ase"); + assert!(r.get("data").is_none(), "a binary file comes back as dataBase64"); + let b64 = r["dataBase64"].as_str().unwrap().to_string(); + assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF")); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"})); + let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(lib.name, "Brand Kit", "named after the file"); + let ink = lib.swatch("Ink").unwrap(); + assert!(ink.spot && ink.global); + assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2))); + assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0))); + assert!(lib.swatch("Sunset").is_none(), "a gradient is left out"); + let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect(); + assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order"); + // Gray colors come back within float rounding. + let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches; + for (w, want) in lib.groups[0].swatches.iter().zip(grays) { + let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else { + panic!("{} isn't gray", w.name) + }; + assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name); + } + // Into the user library folder, and to a path ending in .ase. + let dir = temp_dir("ase-save"); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"})); + assert_eq!(r["library"], "user/Brand Kit.ase"); + assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF")); + assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it"); + let path = dir.join("out.ase").to_string_lossy().to_string(); + assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase"); + assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF")); + let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string(); + assert!(e.contains("vcswatches, gpl, ase or css"), "{e}"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_library_group_stays_one_group_when_a_swatch_has_its_name() { use vectorcraft_testkit::ase::{Block, ase}; diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b1408784..d7e2ef40 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -823,6 +823,20 @@ fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> { }) } +/// The rich document's swatches with a CMYK spot color and a global Lab color added, saved as a +/// swatch exchange file, made once. +fn saved_ase() -> Vec { + static ASE: std::sync::OnceLock> = std::sync::OnceLock::new(); + ASE.get_or_init(|| { + let mut s = rich_session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 1, "m": 0.5, "y": 0, "k": 0.2}, "spot": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}, "global": true})).unwrap(); + let r = s.execute("swatch.library.save", &json!({"format": "ase"})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + }) + .clone() +} + /// A random swatch exchange block (type, body). The body holds a name, a color model when the block /// is a color, and random bytes for the rest. fn arb_ase_block() -> impl Strategy)> { @@ -899,6 +913,19 @@ proptest! { } swatch_exchange("mutated swatch exchange file", &bytes)?; } + + #[test] + fn mutated_saved_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..4_000), edits in prop::collection::vec((0usize..4_000, any::()), 0..12)) { + let mut bytes = saved_ase(); + for (at, b) in edits { + let n = bytes.len(); + bytes[at % n] = b; + } + if let Some(cut) = cut { + bytes.truncate(cut); + } + swatch_exchange("mutated saved swatch exchange file", &bytes)?; + } } /// A small image of each raster format Place reads, with resolution metadata where it has some. diff --git a/crates/ui-egui/src/dialogs/save_swatch_library.rs b/crates/ui-egui/src/dialogs/save_swatch_library.rs index f6fca6af..f2c08c51 100644 --- a/crates/ui-egui/src/dialogs/save_swatch_library.rs +++ b/crates/ui-egui/src/dialogs/save_swatch_library.rs @@ -2,7 +2,7 @@ //! under Window → Swatch Libraries → User Defined) or a file picked in a save dialog (a download //! on the web). OK runs `swatch.library.save`. //! -//! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library +//! Fields: `name`, `format` (`vcswatches`, `gpl`, `ase` or `css`), `user` (save to the user library //! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user` //! (there is a user library folder). //! @@ -34,12 +34,17 @@ pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result) -> Result { - let st = app.session.active().ok_or("no document open")?; - let title = st.title(); - let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string(); + let name = document_name(app).ok_or("no document open")?; Ok(json!({"name": name, "user": user, "__user": user, "selectedOnly": false, "names": names})) } +/// The active document's name without its extension: the library name the save commands use when +/// none is given. +fn document_name(app: &VectorcraftApp) -> Option { + let title = app.session.active()?.title(); + Some(title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string()) +} + fn format_of(d: &Dialog) -> PaletteFormat { PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native) } @@ -62,6 +67,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if format_of(d) == PaletteFormat::Gpl { widgets::dim_label(ui, tl!("GPL palettes keep solid colours only, as RGB.")); } + if format_of(d) == PaletteFormat::Ase { + widgets::dim_label(ui, tl!("Swatch exchange files keep solid colors only.")); + } false } @@ -104,8 +112,10 @@ pub(super) fn params(d: &Dialog) -> Value { p } -/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks -/// (extension `ext`). +/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks. +/// The save panel suggests the file name that a save into the user library folder uses: the name +/// (the document's when it is empty) with extension `ext`. The web downloads the file under that +/// name. pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p: Value) -> Result { if p["user"] == json!(true) { let r = run_and_close(app, cmd, p)?; @@ -113,7 +123,9 @@ pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p return Ok(r); } app.ui.dialog = None; - crate::io::save_command_output(app, cmd, ext, p).map(|path| json!({ "path": path })) + let name = Some(d.str("name").trim().to_string()).filter(|n| !n.is_empty()).or_else(|| document_name(app)).unwrap_or_default(); + let file = vectorcraft_engine::cmd::swatchlib::library_file_name(&name, ext); + crate::io::save_command_output_named(app, cmd, &file, p).map(|path| json!({ "path": path })) } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a47a09dc..7c5abc02 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2639,6 +2639,7 @@ Swash Decorativa Swatch Conflict Conflicto de muestras Swatch Conflicts Conflictos de muestras + Swatch exchange files keep solid colors only. Los archivos de intercambio de muestras solo conservan colores sólidos. Swatch Libraries Bibliotecas de muestras Swatch Libraries Menu Menú Bibliotecas de muestras Swatch Library Biblioteca de muestras diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e5eff4fb..872d3b86 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2636,6 +2636,7 @@ Swash Lettres ornées Swatch Conflict Conflit de nuances Swatch Conflicts Conflits de nuances + Swatch exchange files keep solid colors only. Les fichiers d’échange de nuances ne conservent que les couleurs unies. Swatch Libraries Bibliothèques de nuances Swatch Libraries Menu Menu Bibliothèques de nuances Swatch Library Bibliothèque de nuances diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 65a41e0c..83898442 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2639,6 +2639,7 @@ Swash Ornamentale Swatch Conflict Conflitto campioni Swatch Conflicts Conflitti campioni + Swatch exchange files keep solid colors only. I file di scambio campioni conservano solo i colori pieni. Swatch Libraries Librerie campioni Swatch Libraries Menu Menu Librerie campioni Swatch Library Libreria campioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a3ae3aee..16893889 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2416,6 +2416,7 @@ Swash スワッシュ Swatch Conflict スウォッチの競合 Swatch Conflicts スウォッチの競合 + Swatch exchange files keep solid colors only. スウォッチ交換ファイルにはベタ塗りのカラーのみが保存されます。 Swatch Library スウォッチライブラリ Swatch Name: スウォッチ名: Swatch Options スウォッチオプション diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f6f3cd9..88f15935 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2297,6 +2297,7 @@ Swatch Options Opções de Amostra Swatch Options… Opções de Amostra… Swatch Tiles Blocos de Amostra + Swatch exchange files keep solid colors only. Arquivos de intercâmbio de amostras mantêm apenas cores sólidas. Swatches Amostras Switch workspace Trocar espaço de trabalho Symbol Instance Instância de Símbolo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e335ec3..3ba8f6a1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2635,6 +2635,7 @@ Swash Росчерк Swatch Conflict Конфликт образцов Swatch Conflicts Конфликты образцов + Swatch exchange files keep solid colors only. Файлы обмена образцами сохраняют только сплошные цвета. Swatch Libraries Библиотеки образцов Swatch Libraries Menu Меню «Библиотеки образцов» Swatch Library Библиотека образцов diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a641632e..bd2a12a4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2631,6 +2631,7 @@ Swash Розчерк Swatch Conflict Конфлікт зразків Swatch Conflicts Конфлікти зразків + Swatch exchange files keep solid colors only. Файли обміну зразками зберігають лише суцільні кольори. Swatch Libraries Бібліотеки зразків Swatch Libraries Menu Меню "Бібліотеки зразків" Swatch Library Бібліотека зразків diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f196abf4..da7b3eae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2556,6 +2556,7 @@ Swash 花饰字 Swatch Conflict 色板冲突 Swatch Conflicts 色板冲突 + Swatch exchange files keep solid colors only. 色板交换文件仅保留纯色。 Swatch Libraries 色板库 Swatch Libraries Menu 色板库菜单 Swatch Library 色板库 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 45d32e9a..72edf6c0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2642,6 +2642,7 @@ Swash 花體字 Swatch Conflict 色票衝突 Swatch Conflicts 色票衝突 + Swatch exchange files keep solid colors only. 色票交換檔案僅保留純色。 Swatch Libraries 色票資料庫 Swatch Libraries Menu 色票資料庫選單 Swatch Library 色票資料庫 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 635697f7..7716e52f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -516,6 +516,29 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params Ok(path) } +/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file +/// picked with file name `name` suggested next to the document (the web downloads them as `name`) +/// → where they went. +pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result { + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = take_output(&mut v)?; + let path = write_named(app, None, name, &bytes)?; + app.status(format!("Saved {path}")); + Ok(path) +} + +/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output +/// comes as base64, text (swatch libraries) as is. +fn take_output(v: &mut Value) -> Result, String> { + let o = v.as_object_mut().ok_or("no data")?; + let bytes = match (o.remove("dataBase64"), o.remove("data")) { + (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), + (_, Some(Value::String(text))) => Some(text.into_bytes()), + _ => None, + }; + bytes.ok_or_else(|| "no data".into()) +} + /// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except @@ -532,14 +555,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par path = Some(picked); } let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - // Binary output comes as base64, text (swatch libraries) as is; the result keeps the rest. - let o = v.as_object_mut().ok_or("no data")?; - let bytes = match (o.remove("dataBase64"), o.remove("data")) { - (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), - (_, Some(Value::String(text))) => Some(text.into_bytes()), - _ => None, - }; - let bytes = bytes.ok_or("no data")?; + let bytes = take_output(&mut v)?; let path = match path { Some(p) => p, None => target_path(app, None, ext)?, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2fa9b9a8..fac2e993 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -365,7 +365,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.saveSwatchLibrary", "Save Swatch Library…", "", - "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", + "{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save", ), ("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"), ("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"), diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 3788813d..dc2f1aab 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -617,6 +617,47 @@ mod tests { assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); } + #[test] + fn the_save_dialog_writes_swatch_exchange_files() { + let written = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!("Brand Kit")); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}"); + crate::dialogs::confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow()[0].clone(); + assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name"); + let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap(); + let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count(); + assert_eq!(lib.len(), solid, "every solid color of the document"); + // The web downloads the file under that name. + let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); + let dl = downloads.clone(); + let services = + crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() }; + let mut web = VectorcraftApp::new(Session::new(), services); + web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + web.run("ui.saveSwatchLibrary", json!({})).unwrap(); + let d = web.ui.dialog.as_mut().unwrap(); + d.fields.insert("format".into(), json!("ase")); + d.fields.insert("name".into(), json!(" Brand: Kit. ")); + crate::dialogs::confirm(&mut web).unwrap(); + assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined"); + } + #[test] fn swatch_exchange_files_without_a_path_open_in_the_library_panel() { // A file opened on the web has no path; open_bytes passes its bytes as dataBase64. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1868e055..cd9aec03 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -155,8 +155,9 @@ one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swa Add to Swatches. `window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there; opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the -`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library -folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path). +`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`ase`/`css`, `user`: save to the user library +folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path +and suggests `name` with the format's extension as the file name; the web downloads the file under that name). Graphic style libraries open in the same panel: `window.graphicStyleLibrary {library}` (`library_panel: {kind: "graphicStyles", id}`; `library: null` closes it). Clicking a style there runs `graphicStyle.addFromLibrary {library, @@ -166,7 +167,7 @@ document's graphic styles) and opens it there; opening a `.vcstyles` file with ` Defined libraries are `window.userGraphicStyleLibrary1`…`10` in Window → Graphic Style Libraries. `ui.saveGraphicStyleLibrary {names?}` opens the `saveGraphicStyleLibrary` dialog (fields `name`, `user`: save to the user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `graphicStyle.saveLibrary` (to a file it -asks for a path). +asks for a path and suggests `name` with `.vcstyles` as the file name; the web downloads the file under that name). Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field `color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference diff --git a/docs/mcp.md b/docs/mcp.md index 0b947d11..e8b64b45 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -778,9 +778,12 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back {"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}} {"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}} ``` -`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches -as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB; -CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library +`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"ase"|"css", names?, name?, user?}` writes the document's +swatches as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour +groups; `.gpl` is 8-bit RGB; a swatch exchange `.ase` file keeps solid colors in their own model +(RGB, CMYK, Lab or Gray) as global, spot or process colors, and color groups, writes a tint swatch +as the color it shows and leaves gradients out; CSS writes custom properties); without `path` +it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into the user library folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | dataBase64?, name?}` loads a `.vcswatches`, `.gpl` or swatch exchange (`.ase`) file, or another document's swatches, as a library to add from. From an `.ase` file it reads RGB, CMYK, Lab and Gray colors as global, spot or process From 4b00c2d9682cc44300521c475c79c1b25caeb36a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 06:54:32 -0700 Subject: [PATCH 05/43] Part of #579: macOS fonts registered from other folders are listed too The font scan read only the macOS font folders and the Additional Fonts Folder, so fonts that apps and font managers register with CoreText from their own folders were missing from the font menus, and exports set such type in the fallback font. CoreText's font manager lists them with the installed fonts. - `mac_fonts.rs` lists the files of the fonts the font manager has available (`CTFontManagerCopyAvailableFontURLs`, through objc2-core-text), keeping only file URLs as `open_documents.rs` does (#433). The desktop app and `vectorcraft-cli` install it as they install the DirectWrite lister on Windows, and the scan reads the files outside the font folders in place. - The scan leaves out faces with no outlines the font engine draws (no glyf, CFF, CFF2 or VARC table), on every platform. CoreText lists Apple's PingFang UI interface font (macOS 27), whose outlines are in an hvgl table; if the scan cataloged it, PingFang SC would count as installed, and Chinese characters in fallback text, Japanese kanji included, would draw blank. - Tests: the list holds the installed fonts by full path, a font registered from another folder is listed while it is registered, and a face without outlines the engine draws isn't cataloged. --- Cargo.lock | 13 +++++ ROADMAP.md | 2 +- apps/vectorcraft-cli/Cargo.toml | 6 +++ apps/vectorcraft-cli/src/main.rs | 10 +++- apps/vectorcraft/Cargo.toml | 5 ++ apps/vectorcraft/src/mac_fonts.rs | 84 +++++++++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 4 ++ crates/text/src/fontdb.rs | 15 +++++- crates/text/src/tests_sysfonts.rs | 16 ++++++ docs/mcp.md | 7 ++- 10 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 apps/vectorcraft/src/mac_fonts.rs diff --git a/Cargo.lock b/Cargo.lock index 2da7e754..c2489e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2553,6 +2553,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4323,6 +4332,8 @@ dependencies = [ "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-text", "objc2-foundation 0.3.2", "rfd", "serde_json", @@ -4378,6 +4389,8 @@ name = "vectorcraft-cli" version = "0.7.0" dependencies = [ "log", + "objc2-core-foundation", + "objc2-core-text", "serde_json", "vectorcraft-color", "vectorcraft-doc", diff --git a/ROADMAP.md b/ROADMAP.md index 19549596..e0b912e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -161,7 +161,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 2619644b..1d0e01ae 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -31,6 +31,12 @@ log = { workspace = true } [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } +# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font +# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT. +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] } +objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] } + [dev-dependencies] vectorcraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 9e327c68..2fb790c0 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -9,8 +9,8 @@ //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] //! ``` -// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows -// it for its COM calls, and nothing else may. +// Denied, not forbidden: the font listers shared with the desktop app allow it for their +// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may. #![deny(unsafe_code)] use std::io::Write; @@ -36,6 +36,10 @@ macro_rules! out { }}; } +/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts. +#[cfg(target_os = "macos")] +#[path = "../../vectorcraft/src/mac_fonts.rs"] +mod mac_fonts; mod perf; /// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts. #[cfg(all(windows, not(target_vendor = "win7")))] @@ -104,6 +108,8 @@ fn main() -> ExitCode { // Before any font lookup: the fonts font services load (#579). #[cfg(all(windows, not(target_vendor = "win7")))] system_fonts::install(); + #[cfg(target_os = "macos")] + mac_fonts::install(); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index dc9bf1c0..bb6469a7 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -51,6 +51,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", # Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd. objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] } block2 = "0.6" +# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their +# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with +# objc2-foundation. +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] } +objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] } [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/mac_fonts.rs b/apps/vectorcraft/src/mac_fonts.rs new file mode 100644 index 00000000..a1b5b193 --- /dev/null +++ b/apps/vectorcraft/src/mac_fonts.rs @@ -0,0 +1,84 @@ +//! macOS: the font files CoreText's font manager lists, scanned with the font folders +//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports, +//! MCP) both install it; the CLI shares this file. +//! +//! Apps and font managers can register fonts with the font manager from files in any folder, +//! outside the font folders +//! (). +//! The font manager's list of available fonts holds them +//! (), so +//! VectorCraft lists them as it does on Windows (`system_fonts.rs`), reading the files in place +//! (#579). +//! +//! CoreText is a C API with no safe binding, hence the scoped `unsafe_code` allowance. +#![allow(unsafe_code)] + +use std::collections::BTreeSet; + +use objc2_core_foundation::{CFType, CFURL}; +use objc2_core_text::CTFontManagerCopyAvailableFontURLs; + +/// The most fonts read from the list (a system has a few thousand faces). +const MAX_FONTS: usize = 1 << 16; + +/// Have the font scans read the font files CoreText lists. +pub fn install() { + vectorcraft_text::set_platform_font_files(font_files); +} + +/// The files of the fonts available to the app as CoreText lists them now (fonts registered since +/// the last call included). +fn font_files() -> Vec { + // SAFETY: a CoreText call without arguments; it returns a new array. + let list = unsafe { CTFontManagerCopyAvailableFontURLs() }; + // SAFETY: the array holds CF objects (CFURLs, as CoreText documents); each one is checked to be + // a CFURL before it is read. + let fonts = unsafe { list.cast_unchecked::() }; + // Faces of one file (a collection's) name it once. + let files: BTreeSet = fonts + .iter() + .take(MAX_FONTS) + .filter_map(|f| f.downcast::().ok()) + // Only file URLs, as in `open_documents.rs` (#433). + .filter(|u| u.scheme().is_some_and(|s| s.to_string().eq_ignore_ascii_case("file"))) + .filter_map(|u| u.to_file_path()?.into_os_string().into_string().ok()) + .collect(); + files.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use objc2_core_foundation::CFURL; + use objc2_core_text::{CTFontManagerRegisterFontsForURL, CTFontManagerScope, CTFontManagerUnregisterFontsForURL}; + + /// CoreText lists the installed fonts, by their files' full paths. + #[test] + fn coretext_lists_the_installed_font_files() { + let files = super::font_files(); + assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first()); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts/")), "{} files, {:?}", files.len(), files.first()); + } + + /// A font registered with CoreText from another folder, as apps and font managers register + /// fonts, is listed while it is registered. + #[test] + fn fonts_registered_from_other_folders_are_listed() { + let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS + // installs. + let file = dir.join("Registered Font.ttf"); + std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap(); + let want = std::fs::canonicalize(&file).unwrap(); + let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want)); + assert!(!listed()); + let url = CFURL::from_file_path(&file).unwrap(); + // SAFETY: a file URL, a scope constant and no error out-parameter. + assert!(unsafe { CTFontManagerRegisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) }); + let registered = listed(); + // SAFETY: as above. + unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) }; + std::fs::remove_dir_all(&dir).unwrap(); + assert!(registered); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 6100daea..963fe6c2 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -23,6 +23,8 @@ mod control_server; mod gpu; mod logging; #[cfg(target_os = "macos")] +mod mac_fonts; +#[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] mod open_documents; @@ -393,6 +395,8 @@ fn main() -> std::process::ExitCode { // Before the first font scan (the app's start): the fonts font services load (#579). #[cfg(all(windows, not(target_vendor = "win7")))] system_fonts::install(); + #[cfg(target_os = "macos")] + mac_fonts::install(); let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index eabf0c02..ac05485c 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -783,9 +783,15 @@ fn font_files(path: &Path) -> Vec> { std::fs::read(path).into_iter().collect() } +/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with +/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing. +#[cfg(not(target_arch = "wasm32"))] +const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; + /// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table /// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them -/// megabytes long. +/// megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table) are +/// left out. #[cfg(not(target_arch = "wasm32"))] fn file_face_names(path: &Path) -> Vec { if crate::suitcase::is_suitcase(path) { @@ -825,6 +831,9 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) { + return None; + } let mut table = |tag: &[u8; 4]| -> Option> { let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?; let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); @@ -890,7 +899,9 @@ static PLATFORM_FONT_FILES: std::sync::OnceLock = std::sync:: /// folders, asked again by each scan and each check for installed fonts /// ([`FontDb::installed_fonts_changed`]). The desktop app lists DirectWrite's system font /// collection on Windows: fonts a font service such as Adobe Fonts loads in place, from files -/// outside the font folders and unknown to the registry (#579). Only the first call counts. +/// outside the font folders and unknown to the registry (#579). On macOS it lists the fonts +/// CoreText's font manager has available, which apps and font managers can register from any +/// folder. Only the first call counts. pub fn set_platform_font_files(list: PlatformFontFiles) { #[cfg(not(target_arch = "wasm32"))] // A second call keeps the first lister, as documented. diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 26288442..a5f2e62a 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -104,6 +104,22 @@ fn the_scan_reads_collections_and_loads_fonts_only_when_used() { assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together"); } +#[test] +fn fonts_without_outlines_the_engine_draws_are_left_out() { + let dir = font_dir("outlines"); + // The Bold's TrueType outlines under another tag, as in fonts that keep their outlines in + // Apple's `hvgl` table or hold bitmaps only. The tag keeps the table directory sorted. + let bold = dir.join("Sub/Sysfont-Bold.TTF"); + let mut data = std::fs::read(&bold).unwrap(); + let tables = u16::from_be_bytes([data[4], data[5]]) as usize; + let rec = (0..tables).map(|i| 12 + 16 * i).find(|&r| &data[r..r + 4] == b"glyf").unwrap(); + data[rec..rec + 4].copy_from_slice(b"gxyz"); + std::fs::write(&bold, data).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + assert_eq!(db.styles(FAMILY), ["Regular"]); + assert_eq!(db.load_system_fonts(), 1); +} + #[test] fn rescanning_finds_fonts_installed_since() { let dir = font_dir("rescan"); diff --git a/docs/mcp.md b/docs/mcp.md index 0b947d11..700a03e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2516,9 +2516,12 @@ Type can use the bundled fonts, fonts added to the session and the fonts install the system's and the user's font folders (Windows: `Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, plus fonts registered outside them, such as fonts installed as shortcuts, and in the desktop app and `vectorcraft-cli` (MCP included) the fonts in DirectWrite's system font collection, such as those Adobe Fonts activates while Creative Cloud runs; macOS: `/System/Library/Fonts`, `/Library/Fonts`, -`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts; Linux and BSD: `/usr/share/fonts`, +`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts, plus in the desktop app and `vectorcraft-cli` +the fonts CoreText's font manager lists outside them, such as those apps and font managers register from their own +folders; Linux and BSD: `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.fonts` and the XDG data folders' `fonts`, `~/.local/share/fonts` among them, and in a -Flatpak sandbox the host's fonts). The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup +Flatpak sandbox the host's fonts). Faces without outlines VectorCraft draws (no `glyf`, `CFF`, `CFF2` or `VARC` table, such +as bitmap-only fonts) are left out. The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList` lists every family available, the installed ones included, as the font menus do: without the system's hidden families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names From 75f2145b92bce7054fb76258435a0d4b344ea713 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 06:54:32 -0700 Subject: [PATCH 06/43] Package: fonts found by any of their names are copied, and skipped fonts are named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Package and Document Info › Font Details looked fonts up with FontDb::face and compared the face's family with the name the document stores. A font a document named by another of its names (a PostScript or localized name, as SVG and PDF files do) was reported as not available and left out of the package, although the canvas drew it and exports embedded it. - Package and Font Details resolve fonts with FontDb::resolve, as text.fonts and the renderer do. A style the family lacks is still copied as its closest style; the report now names the style it copies, and Font Details lists the font as substituted, with that style's file and permission. - FontFace::embeddable reads fsType as exports do (Embedding), so fonts that allow bitmap embedding only (bit 9), or that set the restricted bit together with reserved bit 0 (0x0003), are no longer copied. Document Info's permission labels follow the same rule: when a font sets more than one usage bit, the label names the most permissive one. - The Package dialog and its status line name the fonts it didn't copy. --- crates/engine/src/cmd/docinfo.rs | 37 +++++++++++++++------ crates/engine/src/cmd/package.rs | 19 +++++++---- crates/engine/src/tests_package.rs | 30 +++++++++++++++++ crates/text/src/fontdb.rs | 5 +-- crates/text/src/tests_embed.rs | 36 ++++++++++++++++++++ crates/ui-egui/src/dialogs/package.rs | 12 +++++-- crates/ui-egui/src/dialogs/tests_package.rs | 24 +++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 16 files changed, 152 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 05cf1bc3..202be0d5 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_text::embed::Embedding; use super::*; @@ -77,13 +78,17 @@ pub struct Section { pub rows: Vec<(String, String)>, } -/// What a font's `fsType` lets a copy of it do. +/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the +/// most permissive usage bit wins, and bitmap-only fonts may not be embedded. pub fn embedding_label(fs_type: u16) -> &'static str { - match fs_type & 0x000f { - 0x0002 => "embedding not allowed", - 0x0004 => "embedding for preview and print", - 0x0008 => "embedding for editing", - _ => "embedding allowed", + if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { + "embedding not allowed" + } else if fs_type & 0x0008 != 0 { + "embedding for editing" + } else if fs_type & 0x0004 != 0 { + "embedding for preview and print" + } else { + "embedding allowed" } } @@ -136,13 +141,19 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag fonts .iter() .map(|(family, style)| { - let detail = match db.face(family, style) { - Some(f) if f.family.eq_ignore_ascii_case(family) => { + // Found by any of its names; a style the family lacks shows in another. + let detail = match db.resolve(family, style) { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { let file = f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); - format!("{file}; {}", embedding_label(f.fs_type())) + let found = format!("{file}; {}", embedding_label(f.fs_type())); + if m == vectorcraft_text::FontMatch::Style { + format!("substituted: shown in {} {} ({found})", f.family, f.style) + } else { + found + } } - Some(f) => format!("missing: shown in {} {}", f.family, f.style), + Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), None => "missing".into(), }; (format!("{family} {style}"), detail) @@ -396,6 +407,12 @@ mod tests { assert_eq!(embedding_label(0x0002), "embedding not allowed"); assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print"); assert_eq!(embedding_label(0x0008), "embedding for editing"); + // As exports read it: bitmap embedding only, the restricted bit with reserved bit 0, the + // most permissive of several usage bits. + assert_eq!(embedding_label(0x0200), "embedding not allowed"); + assert_eq!(embedding_label(0x0003), "embedding not allowed"); + assert_eq!(embedding_label(0x0006), "embedding for preview and print"); + assert_eq!(embedding_label(0x000c), "embedding for editing"); } #[test] diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 876853f9..046db3a0 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -109,8 +109,15 @@ fn package(s: &mut Session, p: &Value) -> Result { lines.push("FONTS".to_string()); for (family, style) in super::fonts::used_fonts(&st.doc) { let font = format!("{family} {style}"); - let reason = match db.face(&family, &style) { - Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { + // Found by any of its names, as the canvas draws it. + let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing); + // A style the family lacks is shown in its closest style, whose file is copied. + let shown = match &resolved { + Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style), + _ => String::new(), + }; + let reason = match resolved { + Some((f, _)) if f.embeddable() => { let data = f.file_data(); let file = f.path().and_then(Path::file_name).map_or_else( || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), @@ -121,13 +128,13 @@ fn package(s: &mut Session, p: &Value) -> Result { entries.push((format!("Fonts/{file}"), data.to_vec())); fonts += 1; } - lines.push(format!("{font} → Fonts/{file}")); + lines.push(format!("{font}{shown} → Fonts/{file}")); continue; } - Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding", - _ => "not available on this computer", + Some(_) => "its licence doesn't allow embedding", + None => "not available on this computer", }; - lines.push(format!("{font}: {reason}, not copied")); + lines.push(format!("{font}{shown}: {reason}, not copied")); skipped.push(json!({ "font": font, "reason": reason })); } lines.push(String::new()); diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index 2c607fc1..52f30337 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -157,6 +157,36 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +#[test] +fn a_font_named_by_another_of_its_names_is_copied() { + let dir = Folder::new("package-alias"); + let mut s = poster(&dir); + // The bundled Semibold by its PostScript name, as an SVG or a PDF can name it. + run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Alias", "font": "SourceSans3-Semibold"})); + let r = run(&mut s, "file.package", json!({})); + assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), json!([])), "{}", r["skippedFonts"]); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap(); + assert!(report.contains("SourceSans3-Semibold Regular → Fonts/"), "{report}"); + let details = report.lines().find(|l| l.starts_with("SourceSans3-Semibold Regular: ")).unwrap_or_default(); + assert!(details.contains("embedding allowed") && !details.contains("missing"), "{report}"); +} + +#[test] +fn a_substituted_style_names_the_style_shown() { + let dir = Folder::new("package-style"); + let mut s = poster(&dir); + // No Source Sans 3 has a Black Wide, bundled or installed: its closest style stands in, and + // that style's file is copied. + run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})); + let r = run(&mut s, "file.package", json!({})); + assert_eq!(r["skippedFonts"], json!([])); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap(); + assert!(report.contains("Source Sans 3 Black Wide (shown in Source Sans 3 "), "{report}"); + assert!(report.contains("Source Sans 3 Black Wide: substituted: shown in Source Sans 3 "), "{report}"); +} + #[test] fn the_document_info_report_has_every_category() { let dir = Folder::new("docinfo-report"); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index eabf0c02..4812f7f5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -270,9 +270,10 @@ impl FontFace { use skrifa::raw::TableProvider; self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type()) } - /// May the font file be copied along with a document (its licence doesn't restrict embedding)? + /// May the font file be copied along with a document? Its license allows embedding its + /// outlines, as exports check it ([`Self::embedding`]). pub fn embeddable(&self) -> bool { - self.fs_type() & 0x000f != 0x0002 + self.embedding() != crate::embed::Embedding::Forbidden } /// The face's index in its font file ([`Self::file_data`]; collections hold several). pub fn face_index(&self) -> u32 { diff --git a/crates/text/src/tests_embed.rs b/crates/text/src/tests_embed.rs index 495d05ba..8490bfd9 100644 --- a/crates/text/src/tests_embed.rs +++ b/crates/text/src/tests_embed.rs @@ -19,6 +19,42 @@ fn fs_type_flags_decide_the_embedding() { assert_eq!(Embedding::from_fs_type(0x0108), Embedding::Whole, "no subsetting"); } +/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original +/// name) with OS/2 `fsType` set to `fs_type`. +fn licensed_font(family: &str, fs_type: u16) -> Vec { + let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let table = |data: &[u8], tag: &[u8; 4]| { + let f = skrifa::FontRef::new(data).unwrap(); + let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap(); + (r.offset() as usize, r.length() as usize) + }; + let (os2, _) = table(&data, b"OS/2"); + data[os2 + 8..os2 + 10].copy_from_slice(&fs_type.to_be_bytes()); + let (name, len) = table(&data, b"name"); + assert_eq!(family.len(), "Source Sans 3".len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + let mut i = name; + while let Some(at) = data[i..name + len].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// A font file goes with a document (File → Package) only when exports may embed it: not when +/// its license is restricted or allows bitmaps only. +#[test] +fn fonts_exports_may_not_embed_are_not_embeddable() { + let db = FontDb::with_font_dirs(vec![]); + for (family, fs_type, embeddable) in [("Bitmap Sans 3", 0x0200, false), ("Lowbit Sans 3", 0x0003, false), ("Wholed Sans 3", 0x0100, true)] { + assert!(db.add_font(licensed_font(family, fs_type)) > 0, "{family}"); + let face = db.face(family, "Regular").unwrap(); + assert_eq!((face.family.as_str(), face.embeddable()), (family, embeddable), "fsType {fs_type:#06x}"); + } +} + #[test] fn a_subset_holds_only_the_glyphs_used_and_maps_their_characters() { let face = FontDb::global().face("Source Sans 3", "Regular").unwrap(); diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index ca49854f..020c5309 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -114,7 +114,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { app.ui.dialog = None; let n = r["files"].as_array().map_or(0, Vec::len); let missing = r["missingLinks"].as_array().map_or(0, Vec::len); - let note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() }; + // The fonts left out: their license doesn't allow it, or they aren't on this computer. + let fonts = r["skippedFonts"].as_array().into_iter().flatten().filter_map(|f| f["font"].as_str()).collect::>().join(", "); + let mut note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() }; + if !fonts.is_empty() { + note.push_str(&format!(" (fonts not copied: {fonts})")); + } if web { let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default()); let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?; @@ -124,11 +129,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let folder = r["folder"].as_str().unwrap_or_default().to_string(); app.status(format!("Packaged {n} file(s) in {folder}{note}")); - let shown_note = if missing > 0 { + let mut shown_note = if missing > 0 { format!(" ({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())])) } else { String::new() }; + if !fonts.is_empty() { + shown_note.push_str(&format!(" ({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", &fonts)]))); + } let detail = crate::i18n::fmt( tl!("{count} file(s) in {folder}{note}. Show the package folder?"), &[("count", &n.to_string()), ("folder", &folder), ("note", &shown_note)], diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index e6279378..f9607395 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -106,6 +106,30 @@ fn the_web_downloads_a_zip() { assert!(got[0].1 > 100); } +#[test] +fn fonts_left_out_of_the_package_are_named() { + let dir = Folder::new("fonts"); + let mut app = saved(&dir); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Kept"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.packageDialog", json!({})).unwrap(); + let out = dir.path("out"); + std::fs::create_dir_all(&out).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out)); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["skippedFonts"], json!([{"font": "No Such Font Family Regular", "reason": "not available on this computer"}])); + let gone = "fonts not copied: No Such Font Family Regular"; + assert!(app.ui.status.contains(gone), "{}", app.ui.status); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}"); + // The web's download says so too. + app.ui.dialog = None; + app.services.download = Some(Box::new(|_: &str, _: &[u8]| {})); + app.run("ui.packageDialog", json!({})).unwrap(); + confirm(&mut app).unwrap(); + assert!(app.ui.status.contains(gone), "{}", app.ui.status); +} + #[test] fn package_without_a_location_says_so() { let dir = Folder::new("empty"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a47a09dc..6f73b705 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2989,6 +2989,7 @@ Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando {count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete? {count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s) + fonts not copied: {fonts} fuentes no copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas? {count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e5eff4fb..8092777c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2986,6 +2986,7 @@ Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser {count} file(s) in {folder}{note}. Show the package folder? {count} fichier(s) dans {folder}{note}. Afficher le dossier de l’assemblage ? {count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s) + fonts not copied: {fonts} polices non copiées : {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ? {count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 65a41e0c..771c7b9f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2989,6 +2989,7 @@ Scrub Numeric Fields by Dragging Regola i campi numerici trascinando {count} file(s) in {folder}{note}. Show the package folder? {count} file in {folder}{note}. Vuoi mostrare la cartella del pacchetto? {count} linked file(s) not found File collegati non trovati: {count} + fonts not copied: {fonts} font non copiati: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni? {count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a3ae3aee..a8b7084f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2666,6 +2666,7 @@ to → {count} file(s) in {folder}{note}. Show the package folder? {folder} に {count} 個のファイル{note}。パッケージフォルダーを表示しますか? {count} linked file(s) not found リンクファイルが {count} 個見つかりません + fonts not copied: {fonts} コピーされなかったフォント:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか? {count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f6f3cd9..c8976d08 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2590,6 +2590,7 @@ to para {count} file(s) in {folder}{note}. Show the package folder? {count} arquivo(s) em {folder}{note}. Mostrar a pasta do pacote? {count} linked file(s) not found {count} arquivo(s) vinculado(s) não encontrado(s) + fonts not copied: {fonts} fontes não copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) desde que este documento foi salvo. Mostrar as novas versões? {count} more rules: Export writes every one. {count} regras a mais: a Exportação escreve cada uma. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objetos sem nome deixados de fora: nomeie-os, ou ative Objetos Sem Nome no menu do painel. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e335ec3..43edccb4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2985,6 +2985,7 @@ Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием {count} file(s) in {folder}{note}. Show the package folder? Файлов: {count} в {folder}{note}. Показать папку пакета? {count} linked file(s) not found Не найдено связанных файлов: {count} + fonts not copied: {fonts} шрифты не скопированы: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии? {count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a641632e..6f122fb0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2981,6 +2981,7 @@ Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням {count} file(s) in {folder}{note}. Show the package folder? файлів: {count} {folder}{note}. Показати папку пакета? {count} linked file(s) not found не знайдено пов’язаних файлів: {count} + fonts not copied: {fonts} шрифти не скопійовано: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії? {count} more rules: Export writes every one. ще правил: {count}: експорт записує все. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f196abf4..de60e7c3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2909,6 +2909,7 @@ Scrub Numeric Fields by Dragging 拖动以调整数值字段 {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗? {count} linked file(s) not found 找不到 {count} 个链接文件 + fonts not copied: {fonts} 未复制的字体:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗? {count} more rules: Export writes every one. 另有 {count} 条规则:导出时会全部写出。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略过 {count} 个未命名对象:请为它们命名,或在面板菜单中开启“未命名对象”。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 45d32e9a..11e5124d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2997,6 +2997,7 @@ Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎? {count} linked file(s) not found 找不到 {count} 個連結檔案 + fonts not copied: {fonts} 未複製的字體:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎? {count} more rules: Export writes every one. 另有 {count} 條規則:匯出時會全部寫出。 {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略過 {count} 個未命名物件:請為它們命名,或在面板選單中開啟「未命名物件」。 From 8124cf7b4350719989161efa22a0e7ab07a4c8ab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 23:15:36 +0700 Subject: [PATCH 07/43] Clippy 1.99: as_chunks in the Affinity raster tests #763 landed beside #771 with five chunks_exact(4) calls in crates/affinity/tests/raster.rs, which clippy 1.99's chunks_exact_to_as_chunks rejects, so cargo xtask ci failed at clippy on main. Co-Authored-By: Claude Opus 5.5 --- crates/affinity/tests/raster.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/affinity/tests/raster.rs b/crates/affinity/tests/raster.rs index ed98dc51..e477b4d2 100644 --- a/crates/affinity/tests/raster.rs +++ b/crates/affinity/tests/raster.rs @@ -54,8 +54,8 @@ fn source_backed_tiles_retain_the_embedded_jpeg_pixels() { let Pixels::Rgba8(pixels) = &image.pixels else { panic!("expected retained level-zero pixels") }; let expected = image::load_from_memory(&source(&bytes)).unwrap().into_rgba8(); assert_eq!(pixels.as_slice(), expected.as_raw()); - assert!(pixels.chunks_exact(4).all(|p| p[3] == 255)); - assert!(pixels.chunks_exact(4).any(|p| p[0] != p[1])); + assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255)); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1])); } #[test] @@ -80,8 +80,8 @@ fn eight_and_sixteen_bit_channels_retain_the_same_pixel_pattern() { let Pixels::Rgba8(pixels) = &images8[0].pixels else { panic!() }; assert_eq!(pixels.len(), 64 * 48 * 4); assert_eq!(&pixels[..4], &[0, 0, 0, 128]); - assert!(pixels.chunks_exact(4).any(|p| p[3] < 255)); - assert!(pixels.chunks_exact(4).any(|p| p[0] != p[1])); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] < 255)); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[0] != p[1])); // Frozen pixel-retention baseline, including every alpha sample. It is not a render oracle. assert_eq!(crc32fast::hash(pixels), 0xc8ee4dc1); } @@ -98,7 +98,7 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() { } let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() }; assert_eq!(pixels.len(), 80 * 60 * 4); - assert!(pixels.chunks_exact(4).all(|p| p[3] == 255)); + assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255)); assert_eq!(crc32fast::hash(pixels), 0x3720a442); } From 9de418a4a40c0c078f0d54ed7f8b7b1ed9790116 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 23:16:41 +0700 Subject: [PATCH 08/43] ROADMAP: the Arabic families come with a craft-fonts checkout that has them craft-fonts main holds only Noto Sans Arabic so far (the other eleven are in storytold/craft-fonts#11), and releases pin an older craft-fonts commit, so say which builds list them. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3362ac38..d21a9bc8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: craft-fonts' 12 Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar) are in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. From 4b44265965fdd6ffedfd6182a892c1a6c360d344 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 23:40:12 +0700 Subject: [PATCH 09/43] Fix #734: Crop Image shows a crop box on the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Crop Image and the image's Crop Image buttons (Control bar, Properties) now show a crop box on the selected image, as Illustrator does, instead of cropping straight to the artboard (which did nothing for an image inside its artboard). The box starts on the image's part over its artboard; its handles and its inside drag it, always within the image, and the image outside it is dimmed. The Control bar shows its X/Y/W/H with Apply and Cancel; Enter applies and Escape cancels, both back to the Selection tool. Apply runs object.cropImage {rect}, one undo step. - tools: the Crop Image tool (cropimage.rs), with crop_box shared by the tool and the Control bar. - ui: ui.cropImage (enabled with an image selected), the Control bar's crop fields and buttons, "Apply" translated (es, fr, it, ru, uk, ja, zh-hans, zh-hant). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/tools/src/cropimage.rs | 343 ++++++++++++++++++++++++ crates/tools/src/lib.rs | 2 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 14 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/tests_place.rs | 41 +++ crates/ui-egui/src/toolbar.rs | 43 ++- 16 files changed, 452 insertions(+), 7 deletions(-) create mode 100644 crates/tools/src/cropimage.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0502fb43..7f7bf945 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,7 +149,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). @@ -222,7 +222,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | -| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | +| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740) | 2–4 | diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs new file mode 100644 index 00000000..467182a7 --- /dev/null +++ b/crates/tools/src/cropimage.rs @@ -0,0 +1,343 @@ +//! Crop Image (#734): a crop box over the selected image. It starts on the part of the image over +//! its artboard (the whole image when it lies inside one); its handles and its inside drag it, +//! always within the image, and the part outside it is dimmed. Enter (or the Control bar's Apply) +//! crops the image to it (`object.cropImage {rect}`), Escape (or Cancel) leaves it as it was; both +//! go back to the Selection tool. The Control bar edits the box through the `rect` option. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; +use vectorcraft_geom::{Point, Rect, Vec2}; + +use crate::bbox::{Handle, hit_handle}; +use crate::xform::CYAN; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id; Object › Crop Image and the image's Crop Image buttons choose it. +pub const ID: &str = "cropImage"; + +/// The smallest the box gets, in points. +const MIN: f64 = 1.0; + +/// Create the Crop Image tool by id (None = not ours). +pub fn create(id: &str) -> Option> { + (id == ID).then(|| Box::new(CropImageTool::default()) as Box) +} + +/// The selected image (the first image of the selection) and its bounds. +pub fn selected_image(doc: &Document, sel: &Selection) -> Option<(NodeId, Rect)> { + sel.objects.iter().find_map(|id| { + let n = doc.node(*id)?; + matches!(n.kind, NodeKind::Image(_)).then_some(())?; + Some((*id, n.geometric_bounds()?)) + }) +} + +/// Where the box starts for an image with bounds `image`: its part over the artboard its centre is +/// on, or all of it when it lies inside that artboard or isn't on one. +pub fn initial_box(doc: &Document, image: Rect) -> Rect { + doc.artboard_at(image.center()) + .and_then(|i| doc.artboards.get(i)) + .map(|a| a.rect.intersect(image)) + .filter(|r| r.width() >= MIN && r.height() >= MIN) + .unwrap_or(image) +} + +/// The selected image, its bounds and the box over it: `set` kept within the image, or where the +/// box starts ([`initial_box`]). +pub fn crop_box(doc: &Document, sel: &Selection, set: Option) -> Option<(NodeId, Rect, Rect)> { + let (id, image) = selected_image(doc, sel)?; + Some((id, image, set.map_or_else(|| initial_box(doc, image), |r| within(r, image)))) +} + +/// `r` (moved whole) slid back within `image` without changing its size; `r` lies within `image` +/// to begin with, so it fits. +fn slid_within(r: Rect, image: Rect) -> Rect { + let x0 = r.x0.min(image.x1 - r.width()).max(image.x0); + let y0 = r.y0.min(image.y1 - r.height()).max(image.y0); + Rect::new(x0, y0, x0 + r.width(), y0 + r.height()) +} + +/// `r` cut to `image`, at least [`MIN`] wide and high. +fn within(r: Rect, image: Rect) -> Rect { + let r = r.abs().intersect(image); + let w = r.width().max(MIN).min(image.width()); + let h = r.height().max(MIN).min(image.height()); + let x0 = r.x0.min(image.x1 - w).max(image.x0); + let y0 = r.y0.min(image.y1 - h).max(image.y0); + Rect::new(x0, y0, x0 + w, y0 + h) +} + +#[derive(Clone, Copy)] +enum Drag { + /// A handle, with the box as it was pressed. + Handle(Handle, Rect), + /// The box itself, from where it was pressed. + Move(Point, Rect), +} + +#[derive(Default)] +pub struct CropImageTool { + /// The box, for the image it was made for (None: set from outside, for the selected image); + /// None until it's dragged or set. + rect: Option<(Option, Rect)>, + drag: Option, +} + +impl CropImageTool { + /// The selected image, its bounds and the box over it. + fn current(&self, cx: &ToolContext) -> Option<(NodeId, Rect, Rect)> { + let (id, _) = selected_image(cx.doc, cx.selection)?; + let set = self.rect.filter(|(of, _)| of.is_none_or(|of| of == id)).map(|(_, r)| r); + crop_box(cx.doc, cx.selection, set) + } + + /// Crop to the box and go back to the Selection tool (only back when it covers the image). + fn apply(&mut self, cx: &ToolContext) -> Vec { + let current = self.current(cx); + self.reset(); + let mut out = vec![]; + if let Some((_, image, r)) = current + && !same(r, image) + { + out.push(Action::Exec("object.cropImage".into(), json!({ "rect": [r.x0, r.y0, r.width(), r.height()] }))); + } + out.push(Action::SwitchTool("selection".into())); + out + } + + fn reset(&mut self) { + self.rect = None; + self.drag = None; + } +} + +/// Whether two rectangles are the same to a thousandth of a point. +fn same(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-3) +} + +/// The box `r` with the sides handle `h` holds moved to `p`. +fn resized(r: Rect, h: Handle, p: Point) -> Rect { + let (mut x0, mut y0, mut x1, mut y1) = (r.x0, r.y0, r.x1, r.y1); + match h { + Handle::TopLeft | Handle::Left | Handle::BottomLeft => x0 = p.x.min(x1 - MIN), + Handle::TopRight | Handle::Right | Handle::BottomRight => x1 = p.x.max(x0 + MIN), + Handle::Top | Handle::Bottom => {} + } + match h { + Handle::TopLeft | Handle::Top | Handle::TopRight => y0 = p.y.min(y1 - MIN), + Handle::BottomLeft | Handle::Bottom | Handle::BottomRight => y1 = p.y.max(y0 + MIN), + Handle::Left | Handle::Right => {} + } + Rect::new(x0, y0, x1, y1) +} + +impl Tool for CropImageTool { + fn id(&self) -> &'static str { + ID + } + + fn busy(&self) -> bool { + self.drag.is_some() + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let Some((id, image, r)) = self.current(cx) else { return vec![] }; + match ev.kind { + PointerKind::Down => { + self.drag = match hit_handle(r, ev.pos, cx.tol(5.0)) { + Some(h) => Some(Drag::Handle(h, r)), + None => r.contains(ev.pos).then_some(Drag::Move(ev.pos, r)), + }; + } + PointerKind::Drag => { + let next = match self.drag { + Some(Drag::Handle(h, from)) => within(resized(from, h, ev.pos), image), + Some(Drag::Move(start, from)) => { + let d: Vec2 = ev.pos - start; + slid_within(from + d, image) + } + None => return vec![], + }; + self.rect = Some((Some(id), next)); + } + PointerKind::Up => self.drag = None, + PointerKind::DoubleClick => return self.apply(cx), + PointerKind::Move => {} + } + vec![] + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Enter => self.apply(cx), + ToolKey::Escape => { + self.reset(); + vec![Action::SwitchTool("selection".into())] + } + _ => vec![], + } + } + + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Enter | ToolKey::Escape) && self.current(cx).is_some() + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some((_, image, r)) = self.current(cx) else { return vec![] }; + let shade = [0, 0, 0, 110]; + let quad = |x0: f64, y0: f64, x1: f64, y1: f64| [Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)]; + // The image outside the box, dimmed: above, below, then left and right of it. + let mut out: Vec = [ + quad(image.x0, image.y0, image.x1, r.y0), + quad(image.x0, r.y1, image.x1, image.y1), + quad(image.x0, r.y0, r.x0, r.y1), + quad(r.x1, r.y0, image.x1, r.y1), + ] + .into_iter() + .filter(|q| (q[2].x - q[0].x) > 1e-9 && (q[2].y - q[0].y) > 1e-9) + .map(|quad| Overlay::Highlight { quad, color: shade }) + .collect(); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + out.extend(corners.iter().zip(corners.iter().cycle().skip(1)).map(|(a, b)| Overlay::Line { a: *a, b: *b, color: CYAN, dashed: false })); + out.extend(Handle::ALL.iter().map(|h| Overlay::Handle { p: h.pos(r), color: CYAN })); + out.push(Overlay::Label { p: Point::new(r.x0, r.y1), text: cx.size_label(r.width(), r.height()), color: CYAN }); + out + } + + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + let Some((_, _, r)) = self.current(cx) else { return Cursor::Arrow }; + match hit_handle(r, p, cx.tol(5.0)) { + Some(Handle::Top | Handle::Bottom) => Cursor::ResizeV, + Some(Handle::Left | Handle::Right) => Cursor::ResizeH, + Some(Handle::TopLeft | Handle::BottomRight) => Cursor::ResizeNwSe, + Some(Handle::TopRight | Handle::BottomLeft) => Cursor::ResizeNeSw, + None if r.contains(p) => Cursor::Move, + None => Cursor::Arrow, + } + } + + /// `rect`: the box as `[x, y, width, height]` once it's been dragged or set (until then it's + /// [`initial_box`]). + fn options(&self) -> Value { + match self.rect { + Some((_, r)) => json!({ "rect": [r.x0, r.y0, r.width(), r.height()] }), + None => json!({}), + } + } + + /// `rect` `[x, y, width, height]` sets the box (kept within the selected image when used); + /// anything else puts it back where it starts. + fn set_option(&mut self, key: &str, value: &Value) { + if key != "rect" { + return; + } + let n: Vec = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default(); + self.rect = match n.as_slice() { + [x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))), + _ => None, + }; + } + + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.reset(); + vec![] + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_doc::{ImageObject, Node}; + use vectorcraft_geom::Affine; + + use super::*; + use crate::testutil::{cx, paint}; + + /// A 500 × 500 document (one artboard) with a 100 × 50 px image drawn at twice that size from + /// `at`, selected. + fn doc_with_image(at: (f64, f64)) -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let im = ImageObject { + key: "k".into(), + width: 100, + height: 50, + xf: Affine::translate(at) * Affine::scale(2.0), + link: None, + placement: Default::default(), + }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + let mut s = Selection::default(); + s.add(id); + (d, s) + } + + fn drag(t: &mut CropImageTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64)) { + for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + assert!(t.pointer(cx, &PointerEvent::new(kind, x, y)).is_empty()); + } + } + + fn crop_rect(acts: &[Action]) -> Option> { + acts.iter().find_map(|a| match a { + Action::Exec(c, v) if c == "object.cropImage" => Some(v["rect"].as_array()?.iter().filter_map(Value::as_f64).collect()), + _ => None, + }) + } + + #[test] + fn the_box_is_dragged_by_its_handles_and_inside_within_the_image() { + let (d, s) = doc_with_image((50.0, 60.0)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CropImageTool::default(); + // An image inside its artboard: the box starts on all of it, and Enter has nothing to cut. + assert_eq!(t.cursor(&cx, Point::new(50.0, 60.0), Mods::default()), Cursor::ResizeNwSe); + assert_eq!(t.cursor(&cx, Point::new(150.0, 110.0), Mods::default()), Cursor::Move); + assert!(t.claims_key(&cx, ToolKey::Enter)); + // The top-left handle in, then the box dragged left past the image's edge: it stops there. + drag(&mut t, &cx, (50.0, 60.0), (80.0, 70.0)); + assert_eq!(t.options()["rect"], json!([80.0, 70.0, 170.0, 90.0])); + drag(&mut t, &cx, (150.0, 110.0), (50.0, 110.0)); + assert_eq!(t.options()["rect"], json!([50.0, 70.0, 170.0, 90.0])); + // A handle dragged past the opposite side leaves the box at least a point wide. + drag(&mut t, &cx, (220.0, 115.0), (0.0, 115.0)); + assert_eq!(t.options()["rect"], json!([50.0, 70.0, 1.0, 90.0])); + drag(&mut t, &cx, (51.0, 115.0), (180.0, 115.0)); + // The outside of the box is dimmed, the box drawn with its handles. + let ov = t.overlays(&cx); + assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Highlight { .. })).count(), 2, "above and right of the box"); + assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Handle { .. })).count(), 8); + // Enter crops to it and goes back to the Selection tool. + let acts = t.key(&cx, ToolKey::Enter, Mods::default()); + assert_eq!(crop_rect(&acts), Some(vec![50.0, 70.0, 130.0, 90.0])); + assert_eq!(acts.last(), Some(&Action::SwitchTool("selection".into()))); + assert!(t.options().get("rect").is_none(), "the next crop starts afresh"); + assert!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())).is_none(), "the whole image: nothing to cut"); + } + + #[test] + fn the_box_starts_on_the_part_over_the_artboard_and_escape_cancels() { + // 200 × 100 from (-50, 100): its left 50 pt hang off the artboard. + let (d, s) = doc_with_image((-50.0, 100.0)); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CropImageTool::default(); + drag(&mut t, &cx, (100.0, 150.0), (110.0, 150.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); + assert_eq!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())), Some(vec![0.0, 100.0, 150.0, 100.0])); + // The Control bar sets the box; it stays within the image. + t.set_option("rect", &json!([100, 120, 500, 20])); + assert_eq!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())), Some(vec![100.0, 120.0, 50.0, 20.0])); + // Nothing selected: no box, and the keys go elsewhere. + let none = Selection::default(); + let cx = crate::testutil::cx(&d, &none, &p); + assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter)); + } + + #[test] + fn the_tool_is_made_by_its_id() { + assert_eq!(crate::create(ID).id(), ID); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 62bc634e..ae09c29d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,6 +11,7 @@ pub mod bbox; pub mod builder; pub mod catalog; pub mod corners; +pub mod cropimage; pub mod cut; pub mod direct; pub mod distort; @@ -591,6 +592,7 @@ pub fn create(id: &str) -> Box { .or_else(|| extra::create(other)) .or_else(|| slice::create(other)) .or_else(|| printtiling::create(other)) + .or_else(|| cropimage::create(other)) .or_else(|| cut::create(other)) .unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a47a09dc..2a01ba4a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -161,6 +161,7 @@ Appearance panel Panel Apariencia Applies when the linked file is relinked or updated. Se aplica al volver a vincular o actualizar el archivo vinculado. Applies the next time VectorCraft starts. Se aplica la próxima vez que se inicie VectorCraft. + Apply Aplicar Apply Brush Aplicar pincel Apply Character Style Aplicar estilo de carácter Apply Effect Aplicar efecto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e5eff4fb..5807e6c0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -162,6 +162,7 @@ Appearance panel Panneau Aspect Applies when the linked file is relinked or updated. S’applique lorsque le fichier lié est de nouveau lié ou mis à jour. Applies the next time VectorCraft starts. S’applique au prochain démarrage de VectorCraft. + Apply Appliquer Apply Brush Appliquer la forme Apply Character Style Appliquer le style de caractère Apply Effect Appliquer l’effet diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 65a41e0c..657708d7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -161,6 +161,7 @@ Appearance panel Pannello Aspetto Applies when the linked file is relinked or updated. Si applica quando il file collegato viene ricollegato o aggiornato. Applies the next time VectorCraft starts. Si applica al prossimo avvio di VectorCraft. + Apply Applica Apply Brush Applica pennello Apply Character Style Applica stile carattere Apply Effect Applica effetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a3ae3aee..f1b09708 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -342,6 +342,7 @@ Same 共通 Appearance アピアランス Appearance Attribute アピアランス属性 + Apply 適用 Blending Mode 描画モード Fill & Stroke 塗りと線 Fill Color カラー (塗り) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e335ec3..b577c994 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -159,6 +159,7 @@ Appearance panel Панель «Оформление» Applies when the linked file is relinked or updated. Применяется при повторной привязке или обновлении связанного файла. Applies the next time VectorCraft starts. Применяется при следующем запуске VectorCraft. + Apply Применить Apply Brush Применить кисть Apply Character Style Применить стиль символа Apply Effect Применить эффект diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a641632e..a3f6b5df 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -155,6 +155,7 @@ Appearance panel Панель «Оформлення» Applies when the linked file is relinked or updated. Використовується при повторній прив’язці або оновленні пов’язаного файлу. Applies the next time VectorCraft starts. Застосовується при наступному запуску VectorCraft. + Apply Застосувати Apply Brush Застосувати пензель Apply Character Style Застосувати стиль символу Apply Effect Застосувати ефект diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f196abf4..821f0c03 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -144,6 +144,7 @@ Appearance Panel 外观面板 Appearance panel 外观面板 Applies when the linked file is relinked or updated. 在重新链接或更新链接文件时应用。 + Apply 应用 Apply Brush 应用画笔 Apply Character Style 应用字符样式 Apply Effect 应用效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 45d32e9a..b5e2f4f1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -155,6 +155,7 @@ Appearance panel 外觀面板 Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。 Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。 + Apply 套用 Apply Brush 套用筆刷 Apply Character Style 套用字元樣式 Apply Effect 套用效果 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2fa9b9a8..9de3456b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -285,6 +285,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name, or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", ), + ( + "ui.cropImage", + "Crop Image", + "", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( "file.export.pdf", @@ -1140,6 +1146,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), + "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), + "ui.cropImage" => { + app.select_tool(vectorcraft_tools::cropimage::ID); + Ok(json!({"tool": app.session.tool_id()})) + } "ui.paramDialog" => { let cmd = s("command").unwrap_or_default(); let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); @@ -1839,6 +1850,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id["view.goto".len()..].parse::().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len())) } "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "ui.cropImage" => selected_image(app, |_| true), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), @@ -2057,7 +2069,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Expand…", "ui.expandDialog"), c("Expand Appearance", "effect.expandAppearance"), - c("Crop Image", "object.cropImage"), + c("Crop Image", "ui.cropImage"), c("Rasterize…", "object.rasterize"), cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), cp( diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index bd3b49c6..e91be918 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -119,7 +119,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if is_image { crate::panels::image_trace::trace_button(app, ui, ui.available_width()); - actions.push((tl!("Crop Image"), "object.cropImage")); + actions.push((tl!("Crop Image"), "ui.cropImage")); actions.push((tl!("Mask"), "object.maskImage")); } actions.push((tl!("Offset Path"), "object.path.offsetPath")); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 56cb9222..047b28ab 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -436,7 +436,7 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool ui.separator(); link_buttons(app, ui, id, linked, &name, None); crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W); - for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "object.cropImage", 84.0)] { + for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0)] { if widgets::flat_button(ui, label, w).clicked() { crate::menus::invoke(app, cmd, json!({})); } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index dc3bc806..6e1190d4 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -407,3 +407,44 @@ fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() { let d = app.ui.dialog.as_ref().unwrap(); assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents")); } + +/// Tool key `key` as the Control bar's Apply (Enter) and Cancel (Escape) send it. +fn tool_key(app: &mut VectorcraftApp, key: vectorcraft_tools::ToolKey) { + let view = app.view_info(); + let r = app.session.tool_key(key, vectorcraft_tools::Mods::default(), view); + crate::canvas::apply_requests(app, r); +} + +/// #734: Crop Image shows a crop box on the image instead of cropping to the artboard at once (an +/// image inside the artboard had nothing to cut); Apply crops to the box in one undo step, Cancel +/// leaves the image whole, and both go back to the Selection tool. +#[test] +fn crop_image_shows_a_box_that_apply_crops_to() { + use vectorcraft_tools::ToolKey; + let mut app = app(); + place_picked(&mut app, &temp_file("crop.png", &png(100, 50, 72.0))); + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + let whole = selected_image(&app).geometric_bounds().unwrap(); + assert!(crate::menus::enabled(&app, "ui.cropImage")); + app.run("ui.cropImage", json!({})).unwrap(); + assert_eq!(app.session.tool_id(), "cropImage"); + tool_key(&mut app, ToolKey::Escape); + assert_eq!(app.session.tool_id(), "selection"); + assert_eq!(selected_image(&app).geometric_bounds(), Some(whole), "Cancel leaves it whole"); + // The box set to the image's left half, as the Control bar's fields set it, then Apply. + app.run("ui.cropImage", json!({})).unwrap(); + app.run("tool.setOption", json!({"key": "rect", "value": [whole.x0, whole.y0, whole.width() / 2.0, whole.height()]})).unwrap(); + tool_key(&mut app, ToolKey::Enter); + assert_eq!(app.session.tool_id(), "selection"); + let b = selected_image(&app).geometric_bounds().unwrap(); + assert!( + (b.x0 - whole.x0).abs() < 1e-6 && (b.width() - whole.width() / 2.0).abs() < 1e-6 && (b.height() - whole.height()).abs() < 1e-6, + "{b:?} of {whole:?}" + ); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(selected_image(&app).geometric_bounds(), Some(whole), "one undo step"); + // Without an image selected there's nothing to crop. + app.run("select.none", json!({})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err()); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c90c83a1..c543618e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -5,9 +5,9 @@ //! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar. use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2}; -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info}; +use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info}; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, widgets}; @@ -350,6 +350,9 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { if app.session.tool_id() == "puppetWarp" { return puppet_warp_options(app, ui); } + if app.session.tool_id() == vectorcraft_tools::cropimage::ID { + return crop_options(app, ui); + } if app.session.tool_id() == "artboard" { crate::panels::artboards::art_options(app, ui); ui.separator(); @@ -372,6 +375,42 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); } +/// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's +/// `rect` option, then Apply (Enter) and Cancel (Escape). +fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { + let set = app.session.tool_options()["rect"].as_array().and_then(|v| match v.iter().filter_map(Value::as_f64).collect::>()[..] { + [x, y, w, h] => Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h)), + _ => None, + }); + let Some((_, _, r)) = app.session.active().and_then(|st| vectorcraft_tools::cropimage::crop_box(&st.doc, &st.selection, set)) else { return }; + let t = Tokens::get(ui.ctx()); + let units = app.session.general_unit(); + let c = r.center(); + for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", r.width()), ("height", "H:", r.height())] { + widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); + if let Some(nv) = widgets::num_field(ui, ("cb-crop", k), Some(v), units, 80.0) { + let (mut c, mut w, mut h) = (c, r.width(), r.height()); + match k { + "x" => c.x = nv, + "y" => c.y = nv, + "width" => w = nv.max(0.0), + _ => h = nv.max(0.0), + } + let rect = [c.x - w / 2.0, c.y - h / 2.0, w, h]; + app.run("tool.setOption", json!({ "key": "rect", "value": rect })).ok(); + } + } + ui.separator(); + for (label, key) in [(tl!("Apply"), ToolKey::Enter), (tl!("Cancel"), ToolKey::Escape)] { + if widgets::flat_button(ui, label, 64.0).clicked() { + let view = app.view_info(); + let r = app.session.tool_key(key, Mods::default(), view); + crate::canvas::apply_requests(app, r); + } + } + ui.separator(); +} + fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0)); ui.add_space(5.0); From bd8fa795ed30b94149776a70e8277fa2000b4137 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 23:59:50 +0700 Subject: [PATCH 10/43] Fix #776: the Pen continues any open path and joins two paths As in Illustrator, the Pen shows its continue cursor over an end of any open path, selected or not, and a click there selects the path and draws on from that end (reversing it from its first end). While drawing, it shows a join cursor over an end of another open path, and a click joins the two into one path, in one undo step, which finishes the path. path.join takes {ids: [a, b], ends: ["first"|"last", "first"|"last"]} to join the ends asked for (the Pen's join); without them it joins the selected paths' nearest ends as before. Joining a path with itself, or anything that isn't a path, is an error. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/path.rs | 33 ++++++++++++++++--- crates/engine/src/tests_draw2.rs | 54 ++++++++++++++++++++++++++++++++ crates/tools/src/lib.rs | 2 ++ crates/tools/src/pen.rs | 50 +++++++++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 4 ++- crates/ui-egui/src/cursors.rs | 10 +++++- docs/mcp.md | 3 +- 8 files changed, 146 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7f7bf945..466695c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. + - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index acfa6f29..a4f6feaf 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Join", ["Object", "Path"], Some("Cmd+J"), - "{} join two open paths' nearest endpoints, or close a single open path", + "{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path", has_selection, join ), @@ -283,8 +283,27 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> { Some((sp.anchors.first()?.p, sp.anchors.last()?.p)) } -fn join(s: &mut Session, _: &Value) -> Result { - let ids = selected_paths(s)?; +fn join(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.join"; + let ids = match ids_param(p, "ids") { + Some(ids) => { + let st = s.doc()?; + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) { + return Err(bad(C, format!("{} isn't a path", id.0))); + } + ids + } + None => selected_paths(s)?, + }; + // The ends to join, as (reverse the first path, reverse the second): the first path's end + // must come last, the second's first. + let ends = match p.get("ends") { + None => None, + Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::>()).as_deref() { + Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")), + _ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")), + }, + }; match ids.as_slice() { [one] => { let id = *one; @@ -305,6 +324,9 @@ fn join(s: &mut Session, _: &Value) -> Result { } [a, b, ..] => { let (a, b) = (*a, *b); + if a == b { + return Err(bad(C, "join two different paths")); + } s.edit("Join", |d, sel| { let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?; let pa = path_mut(d, a)?; @@ -316,10 +338,11 @@ fn join(s: &mut Session, _: &Value) -> Result { let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else { return Err(EngineError::Other("join needs open paths".into())); }; - // Pick the closest pair of ends. + // The ends asked for, else the closest pair. let cands = [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)]; - let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false)); + let (rx, ry) = + ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry))); if rx { x.reverse(); } diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 760c0f07..93aefb7e 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -790,3 +790,57 @@ fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() { gesture(&mut s, &[(500.0, 400.0)], none); assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5)); } + +/// #776: the Pen continues any open path from the end clicked, selected or not, and while drawing a +/// click on an end of another open path joins the two into one path, in one undo step, which +/// finishes it. +#[test] +fn the_pen_continues_any_open_path_and_joins_another() { + use vectorcraft_tools::Cursor; + let (v, none) = (view(), Mods::default()); + let mut s = session(); + let a = line(&mut s, 100.0, 100.0, 200.0, 100.0); + let b = line(&mut s, 300.0, 200.0, 400.0, 200.0); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("pen", v).unwrap(); + assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue, "an end of a path that isn't selected"); + gesture(&mut s, &[(200.0, 100.0)], none); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin); + assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path"); + assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1); + let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::>(); + assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]); + assert!(s.doc().unwrap().doc.node(b).is_none() && paths(&s).len() == 1, "one path"); + // The join finished it: the next click starts a new path. + gesture(&mut s, &[(500.0, 500.0)], none); + assert_eq!(paths(&s).len(), 2); + // From a path's first end, drawing goes on from there: the path is reversed. + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", v).unwrap(); + s.select_tool("pen", v).unwrap(); + gesture(&mut s, &[(100.0, 100.0)], none); + gesture(&mut s, &[(50.0, 50.0)], none); + assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]); +} + +/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors. +#[test] +fn join_takes_the_paths_and_the_ends_to_join() { + let mut s = session(); + let a = line(&mut s, 0.0, 0.0, 100.0, 0.0); + let b = line(&mut s, 110.0, 0.0, 200.0, 0.0); + // The far ends: a's first to b's last (the nearest pair would be a's last and b's first). + s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap(); + let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect(); + assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]); + let r = rect(&mut s, 0.0, 50.0, 10.0, 10.0); + for bad in [ + json!({"ids": [a.0, a.0]}), + json!({"ids": [a.0, 999]}), + json!({"ids": [a.0, r.0], "ends": ["first"]}), + json!({"ids": [a.0, b.0], "ends": ["first", "middle"]}), + ] { + assert!(s.execute("path.join", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ae09c29d..9f62a366 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -432,6 +432,8 @@ pub enum Cursor { PenDelete, PenClose, PenContinue, + /// While drawing, over an end of another open path (a click joins the two). + PenJoin, /// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with /// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture). PenConvert, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 71b05f2f..9c97f60e 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -11,8 +11,9 @@ //! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes //! it. Cmd held lends the selection tool used last for a drag (the engine's //! `Session::pointer`); the path being drawn goes on afterwards while it stays selected. -//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues -//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a +//! Enter/Esc (or switching tools) ends the path. Clicking an end of an open path continues it +//! (selecting it); while drawing, clicking an end of another open path joins the two into one +//! (#776). The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a //! segment of a selected path adds an anchor there and a click on one of its anchors deletes it //! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected //! blend's spine a click adds a point (on a point no key object sits on: deletes it). @@ -80,6 +81,29 @@ fn active_path(cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> { Some((id, first, last.p, last.h_out)) } +/// An end of an open path (one subpath, not `except`) under `p`, within `tol`: the path the Pen +/// continues or joins to, and whether it's the path's first anchor. Only paths that can be edited, +/// not guides. +fn open_end_at(cx: &ToolContext, p: Point, tol: f64, except: Option) -> Option<(NodeId, bool)> { + let id = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?.leaf; + if Some(id) == except || !cx.doc.is_editable(id) { + return None; + } + let NodeKind::Path { path, guide: false, .. } = &cx.doc.node(id)?.kind else { return None }; + let [sp] = path.subpaths.as_slice() else { return None }; + if sp.closed { + return None; + } + let (first, last) = (sp.anchors.first()?.p, sp.anchors.last()?.p); + if p.distance(last) <= tol { + Some((id, false)) + } else if p.distance(first) <= tol { + Some((id, true)) + } else { + None + } +} + /// The last anchor of `id`'s last subpath: (subpath, anchor). fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { let pd = cx.doc.node(id)?.path_data()?; @@ -160,6 +184,12 @@ impl Tool for PenTool { self.handle = Some((id, si, ai, last)); return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)]; } + // An end of another open path: the two become one, and the path is finished. + if let Some((other, at_first)) = open_end_at(cx, p, tol, Some(id)) { + self.stop(); + let end = if at_first { "first" } else { "last" }; + return vec![Action::Exec("path.join".into(), json!({"ids": [id.0, other.0], "ends": ["last", end]}))]; + } if let Some(acts) = self.alt_convert(cx, ev) { return acts; } @@ -184,6 +214,15 @@ impl Tool for PenTool { } return vec![]; } + // Continue any other open path from the end clicked, selecting it. + if let Some((id, at_first)) = open_end_at(cx, p, tol, None) { + (self.drawing, self.path) = (true, Some(id)); + let mut out = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + if at_first { + out.push(Action::Exec("path.reverse".into(), json!({"ids": [id.0]}))); + } + return out; + } if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) { return vec![act]; } @@ -306,6 +345,9 @@ impl Tool for PenTool { if p.distance(last) <= tol { return Cursor::PenConvert; } + if open_end_at(cx, p, tol, Some(id)).is_some() { + return Cursor::PenJoin; + } } if alt_converts(cx, p, m) { return Cursor::PenConvert; @@ -316,7 +358,9 @@ impl Tool for PenTool { Some([_]) => return Cursor::PenAdd, _ => {} } - if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) { + if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) + || open_end_at(cx, p, tol, None).is_some() + { return Cursor::PenContinue; } match auto_add_delete(cx, p, m, tol) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3b12e66b..1b81eb99 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -483,7 +483,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenConvert => C::Crosshair, + Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => { + C::Crosshair + } Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 8a8eea21..d98a2d2e 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -84,6 +84,12 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) { } "-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)), "/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)), + // Join: a segment running into a square end point. + "j" => { + line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5)); + let sq = [(3.0, -1.0), (7.0, -1.0), (7.0, 3.0), (3.0, 3.0)].map(|(x, y)| b + vec2(x, y)); + poly(p, sq.to_vec(), INK, INK); + } "^" => { line(p, b + vec2(0.0, 6.0), b + vec2(3.0, 0.0)); line(p, b + vec2(3.0, 0.0), b + vec2(6.0, 6.0)); @@ -270,6 +276,7 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::PenDelete => pen(ink, p, "-"), Cursor::PenClose => pen(ink, p, "o"), Cursor::PenContinue => pen(ink, p, "/"), + Cursor::PenJoin => pen(ink, p, "j"), Cursor::PenConvert => pen(ink, p, "^"), Cursor::Text => ibeam(ink, p), Cursor::AddStop => stop_badge(ink, p, true), @@ -425,7 +432,7 @@ mod tests { use crate::VectorcraftApp; /// Every cursor with a glyph. - const GLYPHS: [Cursor; 32] = [ + const GLYPHS: [Cursor; 33] = [ Cursor::Arrow, Cursor::ArrowHollow, Cursor::Move, @@ -441,6 +448,7 @@ mod tests { Cursor::PenDelete, Cursor::PenClose, Cursor::PenContinue, + Cursor::PenJoin, Cursor::PenConvert, Cursor::Text, Cursor::Eyedropper, diff --git a/docs/mcp.md b/docs/mcp.md index 0b947d11..1cfac267 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1150,7 +1150,8 @@ or pulls handles out in line with the neighbouring anchors (a smooth anchor keep (Cut Path at Selected Anchor Points) cuts there and answers `{ids}`: a closed path opens at the cut, its two ends on top of each other, and an open path becomes one path per piece. Each cut leaves one of its two anchors selected, so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect -Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and +Selected End Points) joins the ends again; `path.join {ids: [a, b], ends: ["last", "first"]}` joins the ends asked for +(the Pen's join, #776: drawing on from one open path, a click on another's end makes them one path). `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and `path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id, subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's From 04439bb01db4a631d54fa5660844abe7ea576a6a Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 11:19:08 -0600 Subject: [PATCH 11/43] Fix #784: validate selection IDs and report the resulting selection --- ROADMAP.md | 1 + crates/engine/src/cmd/mod.rs | 15 +++++ crates/engine/src/cmd/select.rs | 46 +++++++++++--- crates/engine/tests/selection_commands.rs | 75 +++++++++++++++++++++++ docs/mcp.md | 9 +++ 5 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/selection_commands.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7f7bf945..91b98d24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). + Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 53080ae8..14b350a0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -304,6 +304,21 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option { pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) } +/// Validate all explicit targets before a command changes the document or selection. +pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { + let values = + p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; + values.iter().map(|value| checked_id(s, value, command)).collect() +} + +pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { + let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; + if s.doc()?.doc.node(id).is_none() { + return Err(bad(command, format!("no such object id {value}"))); + } + Ok(id) +} + pub(crate) fn point_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 27d28684..0dbda515 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -25,14 +25,30 @@ pub fn specs() -> Vec { cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse), cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)), cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)), - cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set), - cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add), + cmd!( + "select.set", + "Select Objects", + [], + None, + "{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)", + has_doc, + set + ), + cmd!( + "select.add", + "Add to Selection", + [], + None, + "{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)", + has_doc, + add + ), cmd!( "select.toggle", "Toggle Selection", [], None, - "{id} or {ids: [id…]}: each selected one leaves the selection, the others join it", + "{id} or {ids: [id…]}: each selected one leaves the selection, the others join it; all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)", has_doc, toggle ), @@ -225,29 +241,39 @@ fn step(s: &mut Session, dir: i64) -> Result { } fn set(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?; - s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?; - ok() + let ids = checked_ids_param(s, p, "ids", "select.set")?; + s.select(|_, sel| sel.set(ids.iter().copied()))?; + selection_result(s) } fn add(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?; + let ids = checked_ids_param(s, p, "ids", "select.add")?; s.select(|_, sel| { for i in ids { sel.add(i); } })?; - ok() + selection_result(s) } fn toggle(s: &mut Session, p: &Value) -> Result { - let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?; + let ids = if p.get("ids").is_some() { + checked_ids_param(s, p, "ids", "select.toggle")? + } else { + let value = p.get("id").ok_or_else(|| bad("select.toggle", "missing id or ids"))?; + vec![checked_id(s, value, "select.toggle")?] + }; s.select(|_, sel| { for id in ids { sel.toggle(id); } })?; - ok() + selection_result(s) +} + +fn selection_result(s: &Session) -> Result { + let selection = &s.doc()?.selection; + Ok(json!({ "count": selection.len(), "ids": selection.objects })) } fn key(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/tests/selection_commands.rs b/crates/engine/tests/selection_commands.rs new file mode 100644 index 00000000..95753367 --- /dev/null +++ b/crates/engine/tests/selection_commands.rs @@ -0,0 +1,75 @@ +//! Selection command results and validation for agent callers (#784). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::{EngineError, Session}; + +fn session() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut rectangle = + |x| NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + let a = rectangle(0); + let b = rectangle(20); + (s, a, b) +} + +#[test] +fn selection_commands_reject_invalid_ids_without_changing_state() { + for command in ["select.set", "select.add", "select.toggle"] { + let (mut s, a, b) = session(); + s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "add"})).unwrap(); + s.execute("select.key", &json!({"id": b.0})).unwrap(); + for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true), json!(u64::MAX)] { + let selection = s.active().unwrap().selection.clone(); + let revision = s.active().unwrap().revision; + let journal = s.journal.clone(); + let error = s.execute(command, &json!({"ids": [b.0, invalid]})).unwrap_err(); + assert!(matches!(error, EngineError::BadParams { .. }), "{command}: {error}"); + assert!(error.to_string().contains(&invalid.to_string()), "the error must identify {invalid}: {error}"); + assert_eq!(s.active().unwrap().selection, selection, "{command}: {invalid}"); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } +} + +#[test] +fn selection_commands_require_an_id_array() { + for command in ["select.set", "select.add", "select.toggle"] { + let (mut s, _, _) = session(); + for params in [json!({}), json!({"ids": null}), json!({"ids": 2}), json!({"ids": "2"}), json!({"ids": true})] { + let selection = s.active().unwrap().selection.clone(); + assert!(matches!(s.execute(command, ¶ms), Err(EngineError::BadParams { .. })), "{command}: {params}"); + assert_eq!(s.active().unwrap().selection, selection); + } + } +} + +#[test] +fn selection_commands_report_the_resulting_selection() { + let (mut s, a, b) = session(); + assert_eq!(s.execute("select.set", &json!({"ids": [b.0, a.0, b.0]})).unwrap(), json!({"count": 2, "ids": [b.0, a.0]})); + assert_eq!(s.execute("select.set", &json!({"ids": []})).unwrap(), json!({"count": 0, "ids": []})); + assert_eq!(s.execute("select.add", &json!({"ids": [a.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]})); + assert_eq!(s.execute("select.add", &json!({"ids": [b.0]})).unwrap(), json!({"count": 2, "ids": [a.0, b.0]})); + assert_eq!(s.execute("select.toggle", &json!({"id": a.0})).unwrap(), json!({"count": 1, "ids": [b.0]})); + assert_eq!(s.execute("select.toggle", &json!({"ids": [b.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]})); + for command in ["select.add", "select.toggle"] { + assert_eq!(s.execute(command, &json!({"ids": []})).unwrap(), json!({"count": 1, "ids": [a.0]})); + } +} + +#[test] +fn single_id_toggle_validates_before_mutating() { + let (mut s, a, _) = session(); + for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true)] { + let selection = s.active().unwrap().selection.clone(); + assert!(matches!(s.execute("select.toggle", &json!({"id": invalid})), Err(EngineError::BadParams { .. }))); + assert_eq!(s.active().unwrap().selection, selection); + } + // A malformed array must not silently fall back to a valid single id. + assert!(s.execute("select.toggle", &json!({"ids": "bad", "id": a.0})).is_err()); +} diff --git a/docs/mcp.md b/docs/mcp.md index 0b947d11..1de61e6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -612,6 +612,15 @@ the one highlighted layer or group row, else the one selected group, else the cu so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel underlines clipping-path names. +## Object selection + +`select.set {ids}` replaces the selection, `select.add {ids}` adds to it, and +`select.toggle {id}` or `select.toggle {ids}` toggles each target. +Every id must be a non-negative integer naming an existing object. +A malformed or unknown id is an error naming that value; the entire selection is left unchanged. +All three commands return `{count, ids}` for the resulting selection, in selection order. +An empty array clears the selection for `select.set` and leaves it alone for Add and Toggle. + ## Saved selections Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default From 636d9b24c89bc3f5f4a61390d0600b52d5ee0828 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 11:33:23 -0600 Subject: [PATCH 12/43] Fix #786: clear exact compound members and explicit targets --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 34 +++--- crates/engine/src/lib.rs | 5 +- crates/engine/tests/clear_commands.rs | 145 ++++++++++++++++++++++++++ docs/mcp.md | 8 ++ 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 crates/engine/tests/clear_commands.rs diff --git a/ROADMAP.md b/ROADMAP.md index 91b98d24..72ee63aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index db23d135..fb87ce30 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,6 +1,6 @@ //! File (document-level) and Edit commands, plus document queries. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -250,29 +250,39 @@ fn cut(s: &mut Session, p: &Value) -> Result { if s.tool_id() == "artboard" { return super::panelcmds::artboard_copy(s, &json!({}), true); } + // Copy promotes a compound member to its compound. Cut must remove the same objects it + // copied, while Clear on its own deletes exactly the selected members. + let ids = selected_roots(s)?; copy(s, p)?; - clear(s, &json!({})) + if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) } } fn clear(s: &mut Session, p: &Value) -> Result { + let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None }; // Direct-selected anchors: delete those anchors instead of whole objects. - if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() { + if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() { return super::path::delete_anchors(s, p); } // Selected ruler guides (selected on their own). - if ids_param(p, "ids").is_none() && s.doc()?.selection.is_empty() { + if explicit.is_none() && s.doc()?.selection.is_empty() { return super::docmenu::guide_remove(s, &json!({})); } - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; + let st = s.doc()?; + let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc)); + // Keep the selected ids themselves, without promoting compound members. Delete a selected + // ancestor just once; its selected descendants leave with it. Layers themselves are kept. + let targets: BTreeSet<_> = targets.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())).collect(); + let ids: Vec<_> = targets + .iter() + .copied() + .filter(|id| !st.doc.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor))) + .collect(); + if ids.is_empty() { + return ok(); + } s.edit("Clear", |d, sel| { for id in &ids { - if d.node(*id).is_some_and(|n| n.is_layer()) { - continue; - } - let _ = d.remove(*id); + d.remove(*id)?; } sel.clear(); Ok(()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 590f7d63..b25a07b6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1055,7 +1055,10 @@ impl Session { /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - if let Err(why) = (spec.enabled)(self) { + // Clear's menu needs a selection; an agent supplying explicit targets only needs a + // document. The command validates those targets before making any edit. + let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) }; + if let Err(why) = enabled { return Err(EngineError::Disabled(id.to_string(), why)); } // Only top-level commands are journaled (commands that call other commands would otherwise diff --git a/crates/engine/tests/clear_commands.rs b/crates/engine/tests/clear_commands.rs new file mode 100644 index 00000000..80fa4660 --- /dev/null +++ b/crates/engine/tests/clear_commands.rs @@ -0,0 +1,145 @@ +//! Clear deletes its exact targets, including compound members (#786). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::{EngineError, Session}; + +fn compound() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut rectangle = + |x| NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap()); + let a = rectangle(10); + let b = rectangle(50); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + (s, a, b, c) +} + +#[test] +fn clear_selected_compound_member_preserves_its_sibling_and_is_undoable() { + let (mut s, a, b, c) = compound(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); + assert!(s.active().unwrap().selection.is_empty()); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Clear"); + assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c)); + assert_eq!(s.active().unwrap().selection.objects, vec![a]); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); +} + +#[test] +fn clear_explicit_ids_works_without_a_selection_and_overrides_selected_anchors() { + let (mut s, a, b, c) = compound(); + s.execute("select.none", &json!({})).unwrap(); + let info = s.commands().into_iter().find(|info| info.id == "edit.clear").unwrap(); + assert!(!info.enabled, "the menu stays disabled with no selection"); + s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap(); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]]})).unwrap(); + let sibling = s.active().unwrap().doc.node(b).unwrap().clone(); + s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap(); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.node(b).unwrap(), &sibling); +} + +#[test] +fn clear_explicit_targets_are_validated_atomically() { + let (mut s, a, _, _) = compound(); + s.execute("select.none", &json!({})).unwrap(); + for ids in [json!([a.0, 999]), json!([a.0, -1]), json!([a.0, "bad"]), json!([a.0, 2.5]), json!(null), json!(a.0)] { + let doc = s.active().unwrap().doc.clone(); + let journal = s.journal.clone(); + assert!(matches!(s.execute("edit.clear", &json!({"ids": ids})), Err(EngineError::BadParams { .. })), "{ids}"); + assert_eq!(s.active().unwrap().doc, doc); + assert_eq!(s.journal, journal); + } + assert!(s.execute("edit.clear", &json!({})).is_err()); + assert!(Session::new().execute("edit.clear", &json!({"ids": [a.0]})).is_err()); +} + +#[test] +fn clear_compound_and_member_together_deletes_the_compound() { + for explicit in [false, true] { + let (mut s, a, b, c) = compound(); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + s.execute("edit.clear", &if explicit { json!({"ids": [a.0, c.0, c.0]}) } else { json!({}) }).unwrap(); + for id in [a, b, c] { + assert!(s.active().unwrap().doc.node(id).is_none()); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c)); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); + } +} + +#[test] +fn cut_compound_member_keeps_copy_and_delete_targets_consistent() { + let (mut s, a, b, c) = compound(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(s.clipboard.nodes.len(), 1); + assert_eq!(s.clipboard.nodes[0].id, c); + assert!(s.active().unwrap().doc.node(c).is_none()); + s.execute("edit.pasteInPlace", &json!({})).unwrap(); + let pasted = s.active().unwrap().selection.objects[0]; + assert_eq!(s.active().unwrap().doc.node(pasted).unwrap().children().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c)); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); +} + +#[test] +fn clear_direct_selected_anchors_keeps_the_rest_of_the_path() { + let (mut s, a, b, c) = compound(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); +} + +#[test] +fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() { + let (mut s, a, _, _) = compound(); + s.execute("guide.add", &json!({"vertical": true, "pos": 10})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + let history = s.active().unwrap().history.undo.len(); + let selection = s.active().unwrap().selection.clone(); + s.execute("edit.clear", &json!({"ids": []})).unwrap(); + assert_eq!(s.active().unwrap().history.undo.len(), history); + assert_eq!(s.active().unwrap().selection, selection); + assert_eq!(s.active().unwrap().doc.guides.len(), 1); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.active().unwrap().doc.guides.is_empty()); + assert!(s.active().unwrap().doc.node(a).is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.guides.len(), 1); +} + +#[test] +fn clear_keeps_layers_but_deletes_an_explicitly_targeted_child() { + let (mut s, a, b, c) = compound(); + let layer = s.active().unwrap().doc.layers[0].id; + s.execute("select.none", &json!({})).unwrap(); + s.execute("edit.clear", &json!({"ids": [layer.0, a.0]})).unwrap(); + assert!(s.active().unwrap().doc.node(layer).is_some()); + assert!(s.active().unwrap().doc.node(a).is_none()); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); +} + +#[test] +fn cut_direct_selected_anchors_retains_anchor_deletion() { + let (mut s, a, b, c) = compound(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("edit.cut", &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3); + assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c)); +} diff --git a/docs/mcp.md b/docs/mcp.md index 1de61e6e..0893d843 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -621,6 +621,14 @@ A malformed or unknown id is an error naming that value; the entire selection is All three commands return `{count, ids}` for the resulting selection, in selection order. An empty array clears the selection for `select.set` and leaves it alone for Add and Toggle. +`edit.clear {ids?}` deletes the exact selected objects, including individual compound-path members; +their unselected siblings remain in the compound. Supplying `ids` works without a selection and +overrides selected anchors or guides. Every explicit id is validated before any deletion; a malformed +or unknown id leaves the document unchanged. An empty `ids` array does nothing. Layers themselves +are kept, and selecting an ancestor together with its descendants deletes that subtree once. +Without `ids`, direct-selected anchors or selected ruler guides retain their usual Clear behavior. +Cut still removes the objects it copied, including a whole compound when a member is selected. + ## Saved selections Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default From 0928cafab2312e4a1f98620f638b3fbebec31bb5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 00:36:14 +0700 Subject: [PATCH 13/43] Native menu test: judge idle frames while the global generations hold still key_equivalents_run_through_the_normal_shortcut_path_once failed about one full ui-egui run in four ("the menus weren't read again", 5 vs 4). Each app mirrors its shortcut overrides into a process-wide store every frame, bumping shortcut_editor::GENERATION, and the plug-in registry's revision is process-wide too. Tests running alongside with other shortcuts flip them, which rightly makes the menus read again. The test now checks two idle frames during which those generations held still (trying up to 50 times), so it still fails if idle frames re-read the menus on their own. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests_nativemenu.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index 9ffe2814..afcca1ad 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -348,11 +348,18 @@ fn key_equivalents_run_through_the_normal_shortcut_path_once() { let before = undo(&m); m.key_equivalent("Cmd+Z"); assert_eq!(undo(&m), before - 1, "⌘Z undid one step"); - let synced = m.fake.synced(); - assert!(synced >= 1, "the menus were built"); - m.frame(vec![]); - m.frame(vec![]); - assert_eq!(m.fake.synced(), synced, "nothing changed: the menus weren't read again"); + assert!(m.fake.synced() >= 1, "the menus were built"); + // Idle frames don't read the menus again. The shortcut and plug-in generations are + // process-wide, and tests running alongside with other shortcuts bump them (rightly reading + // the menus again), so judge two idle frames in which they held still. + let globals = || (crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed), crate::menus::plugin_revision()); + let quiet = (0..50).any(|_| { + let (before, synced) = (globals(), m.fake.synced()); + m.frame(vec![]); + m.frame(vec![]); + globals() == before && m.fake.synced() == synced + }); + assert!(quiet, "nothing changed: the menus weren't read again"); assert_eq!(crate::control::inspect(&m.app, &m.ctx)["nativeMenuBar"], json!(true)); } From f300321e282d5986d40d0f5e4507504406ea5a4d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 00:51:18 +0700 Subject: [PATCH 14/43] Part of #793: Artboard Options lists Name, Width, Height; one-key shortcuts follow the key on any layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Artboard Options listed its fields by key (Height, Name, Width); it now lists Name, then Width and Height. - One-key tool and command shortcuts (V, P, X, D…) matched the character typed, so with a layout without Latin letters (Persian, Arabic, Russian, Greek…) they did nothing. When the text typed holds no ASCII character, the key's position stands in: the V key selects the Selection tool whatever it types. Latin layouts (AZERTY…) keep the letter typed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/form.rs | 1 + crates/ui-egui/src/dialogs/tests.rs | 11 ++++++++ crates/ui-egui/src/shortcuts.rs | 41 +++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index cc5e106e..73ee6cad 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -67,6 +67,7 @@ fn order(kind: &str) -> &'static [&'static str] { match kind { "offsetPath" => &["offset", "joins", "miterLimit"], "splitIntoGrid" => &["rows", "columns", "gutter"], + "artboardOptions" => &["name", "width", "height"], _ => &[], } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 52015f88..2ca0495a 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -370,3 +370,14 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { let after = bounds(&app); assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); } + +/// #793: Artboard Options lists its fields as Name, Width, Height, not by key. +#[test] +fn artboard_options_lists_name_then_width_and_height() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap(); + open(&mut app, "artboardOptions", json!({"index": 0, "name": "Artboard 1", "width": 612, "height": 792, "x": 0, "y": 0})); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} in {text}")); + assert!(at("Name") < at("Width") && at("Width") < at("Height"), "{text}"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index dbdb941d..dc25ceb6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -414,12 +414,18 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { { let _ = app.run("edit.clear", json!({})); } - // Single-key tool shortcuts (no Cmd/Ctrl/Alt). + // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, + // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). let events: Vec<(String, Modifiers)> = ctx.input(|i| { + let mut physical = None; i.events .iter() .filter_map(|e| match e { - egui::Event::Text(t) => Some((t.clone(), i.modifiers)), + egui::Event::Key { physical_key, pressed: true, .. } => { + physical = *physical_key; + None + } + egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)), _ => None, }) .collect() @@ -445,6 +451,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself, +/// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a +/// US layout, so V selects the Selection tool whatever the V key types. +fn latin(text: &str, physical: Option) -> String { + match physical { + Some(k) if !text.is_ascii() => k.symbol_or_name().to_lowercase(), + _ => text.to_string(), + } +} + /// The digit a number-row or keypad key types. fn digit_of(k: Key) -> Option { const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; @@ -678,6 +694,27 @@ mod tests { assert_eq!(app.session.tool_id(), "curvature"); } + /// #793: with a layout without Latin letters the one-key shortcuts go by the key's position: + /// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection + /// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A. + #[test] + fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let typed = |key, physical, text: &str| { + vec![ + egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text(text.into()), + ] + }; + frame(&mut app, typed(Key::P, Key::P, "ح")); + assert_eq!(app.session.tool_id(), "pen"); + frame(&mut app, typed(Key::V, Key::V, "м")); + assert_eq!(app.session.tool_id(), "selection"); + frame(&mut app, typed(Key::A, Key::Q, "a")); + assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed"); + } + #[test] fn plus_typed_any_way_zooms_in() { assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); From 01f329aa99fa0c9517311c7b6cf7233ffc207742 Mon Sep 17 00:00:00 2001 From: Dmytro Matviichuk Date: Fri, 9 Oct 2026 19:51:43 +0200 Subject: [PATCH 15/43] i18n: correct Ukrainian wording and spelling (#747) Co-authored-by: Codex --- crates/ui-egui/src/i18n/uk.tsv | 184 ++++++++++++++++----------------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a3f6b5df..598699ae 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -216,7 +216,7 @@ Auto Spell Check Автоматична перевірка орфографії Auto-Collapse Iconic Panels Автоматично згортати значки панелей Show Tool Group Labels Показувати назви груп інструментів - Auto-Rotate (turn the paper to each artboard) Автоповорот (повертати лист по кожній монтажній області) + Auto-Rotate (turn the paper to each artboard) Автоматичний поворот (повертати аркуш відповідно до кожної монтажної області) Automatic flatness Автоматична площинність Automatically Автоматично Automatically Save Recovery Data Автоматично зберігати дані відновлення @@ -240,7 +240,7 @@ Black Чорний Black and White Чорно-білий Black and White Logo Чорно-білий логотип - BLANK DOCUMENT PRESETS НАБОРИ ПОРОЖНИХ ДОКУМЕНТІВ + BLANK DOCUMENT PRESETS НАБОРИ ПОРОЖНІХ ДОКУМЕНТІВ Bleed Виліт Bleed: Виліт: Bleeds Вильоти @@ -435,7 +435,7 @@ Compatible Gradient and Gradient Mesh Printing Сумісний друк градієнтів та градієнтних сіток Complement Додатковий Complexity: Складність: - Composite colours are converted to it; separations separate with it when it is a CMYK profile. Складові кольори перетворюються на нього; кольороподіли розділяються з ним, якщо це профіль CMYK. + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Складові кольори перетворюються за цим профілем; кольороподіл також виконується за ним, якщо це профіль CMYK. Compound Path Складений контур Compress (RLE) Стиснути (RLE) Compress text and line art Стискати текст та штриховий малюнок @@ -477,7 +477,7 @@ Convert to Shape Перетворити на фігуру Convert to sRGB Перетворити на sRGB Convert: Перетворити: - Copied the CSS of {rules} object(s) Скопійовано CSS для {rules} об’єкта(ів) + Copied the CSS of {rules} object(s) Скопійовано CSS об’єктів: {rules} Copies: Копії: Copy Копіювати Copy (make a transformed copy) Копіювати (створити перетворену копію) @@ -542,11 +542,11 @@ CSS Properties Властивості CSS Ctrl Ctrl Currency & Symbols Валюти та символи - Current Brush Поточна пензель + Current Brush Поточний пензель Current Colors ({total}) Поточні кольори ({total}) Current Symbol Поточний символ Curvature Кривизна - Curvature Tool Інструмент «Кривізна» + Curvature Tool Інструмент «Кривизна» Curves… Криві… Custom Користувацький Customize… Налаштувати… @@ -615,7 +615,7 @@ Delete these {n} graphic styles? Видалити ці графічні стилі ({n})? Delete this saved preset Видалити цей збережений набір Delete this {n} graphic style? Видалити цей графічний стиль ({n})? - Delete Transparency Flattener Preset Видалити набір об’єднання прозоростей + Delete Transparency Flattener Preset Видалити набір зведення прозорості Delete Width Point Видалити точку ширини Delete Workspace Видалити робоче середовище Delete {kind} Style Видалити стиль {kind} @@ -785,21 +785,21 @@ Ellipse Еліпс Ellipse Tool Інструмент «Еліпс» Ellipse… Еліпс… - Ellipsis Багатокрапка - Em Box Bottom/Left Низ/ліво кегельного майданчика + Ellipsis Трикрапка + Em Box Bottom/Left Нижній/лівий край кегельного майданчика Em Box Center Центр кегельного майданчика Em Box Top/Right Верх/право кегельного майданчика ICF Top/Right Верх/право ICF - ICF Bottom/Left Низ/ліво ICF + ICF Bottom/Left Нижній/лівий край ICF Em Dash Довге тире Em Space Кругла шпація Embed Вбудувати Embed Fonts (for other applications) Вбудувати шрифти (для інших програм) Embed Fonts (Glyphs Used) Вбудувати шрифти (використані гліфи) - Embed ICC Profile Використати профіль ICC - Embed ICC Profiles Використати профілі ICC - Embed Image Використати зображення - Embed Image(s) Використати зображення(и) + Embed ICC Profile Вбудувати профіль ICC + Embed ICC Profiles Вбудувати профілі ICC + Embed Image Вбудувати зображення + Embed Image(s) Вбудувати зображення Embed page thumbnails Вбудувати мініатюри сторінок Embedded Вбудовано Embedded Image Впроваджене зображення @@ -866,7 +866,7 @@ Expand… Розібрати… Export Експорт Export All… Експортувати все… - Export and Clipboard Transparency Flattener Settings Параметри об’єднання прозоростей для експорту та буфера обміну + Export and Clipboard Transparency Flattener Settings Параметри зведення прозорості для експорту та буфера обміну Export Artboard Експортувати монтажну область Export As Експортувати як Export As PNG… Експортувати як PNG… @@ -894,7 +894,7 @@ Export Settings Параметри експорту Export the selected assets (else all of them) Експортувати виділені ресурси (інакше все) Export to: Експортувати до: - Export Transparency Flattener Presets Експортувати набори поєднання прозоростей + Export Transparency Flattener Presets Експортувати набори зведення прозорості Export {count} Selected Експортувати вибрані ({count}) Export: Експорт: Exported {count} file(s) to {folder} Експортовано файли: {count} до {folder} @@ -967,18 +967,18 @@ Fit to Artwork Bounds Вписати у межі малюнка Fit to Selected Art Вписати у виділений малюнок Flag… Прапор… - Flare Блік - Flare Tool Інструмент «Блик» - Flare Tool Options Параметри інструменту «Блик» + Flare Відблиск + Flare Tool Інструмент «Відблиск» + Flare Tool Options Параметри інструменту «Відблиск» Flat Image Плоске зображення Flat Logo Плоский логотип Flatness: Площинність: Flatten Artwork Об’єднати малюнок - Flatten Transparency Об’єднати прозорості - Flatten Transparency… Поєднати прозорості… - Flattener preset: Набір об’єднання: - Flattener presets Набори об’єднання - Flattener Preview Попередній перегляд об’єднання + Flatten Transparency Звести прозорість + Flatten Transparency… Звести прозорість… + Flattener preset: Набір зведення: + Flattener presets Набори зведення + Flattener Preview Попередній перегляд зведення Flip Across Відобразити поперек Flip Along Відобразити вздовж Flip Along Horizontal Axis Відобразити по горизонтальній осі @@ -1114,9 +1114,9 @@ More Options Додаткові параметри Show Properties Panel Показати панель «Властивості» Hide Corner Widget Приховати кутовий віджет - Hide Corner Widget for angles greater than Приховувати кутовий віджет для кутів. + Hide Corner Widget for angles greater than Приховувати кутовий віджет для кутів, більших за Hide Edges Приховати краї - Hide Gradient Annotator Сховати анотатор градієнта + Hide Gradient Annotator Приховати маркер градієнта Hide Grid Приховати сітку Hide Guides Приховати напрямні Hide Hidden Characters Приховати приховані символи @@ -1170,7 +1170,7 @@ Ignore White Ігнорувати білий Palette: Палітра: Limited Обмежена - Full Tone Повнотонова + Full Tone Повний діапазон тонів Document Library Бібліотека документа Library: Бібліотека: Image Зображення @@ -1188,7 +1188,7 @@ Import PDF Presets Імпортувати набори PDF Import Perspective Grid Presets Імпортувати набори сітки перспективи Import Print Presets Імпортувати набори друку - Import Transparency Flattener Presets Імпортувати набори поєднання прозоростей + Import Transparency Flattener Presets Імпортувати набори зведення прозорості Imported {what}: {} Імпортовано: {what}: {} Import… Імпорт… In Tangent Handles Маркери вхідної дотичної @@ -1242,11 +1242,11 @@ Join the ArtCraft community on Discord (discord.gg/artcraft) Приєднуйтесь до спільноти ArtCraft Discord (discord.gg/artcraft) Join the lines of each paragraph Поєднувати рядки кожного абзацу Join Tool Інструмент «З’єднання» - Justification… Виняток… - Justify all lines Вимкнути всі рядки - Justify with last line aligned center Вимкнути з останнім рядком по центру - Justify with last line aligned left Вимкнути з останнім рядком по лівому краю - Justify with last line aligned right Вимкнути з останнім рядком з правого краю + Justification… Вирівнювання за шириною… + Justify all lines Вирівняти всі рядки за шириною + Justify with last line aligned center Вирівняти за шириною, останній рядок — по центру + Justify with last line aligned left Вирівняти за шириною, останній рядок — ліворуч + Justify with last line aligned right Вирівняти за шириною, останній рядок — праворуч Keep a link to an image file instead of only embedding it Зберігати зв’язок із файлом зображення замість простого впровадження Keep both sides the same width Зберігати однакову ширину обох сторін Keep Formatting Зберігати форматування @@ -1302,7 +1302,7 @@ Left-to-Right Paragraph Direction Напрямок абзацу зліва направо Left: Зліва: Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (гама 1.8) - Legacy vector clipboard Застарілий вектор буфер обміну + Legacy vector clipboard Застарілий векторний буфер обміну Legacy vector clipboard (no transparency) Застарілий векторний буфер обміну (без прозорості) Less Менше Letter Letter @@ -1424,7 +1424,7 @@ Matte: Підкладка: Maximize Розгорнути Maximum Максимум - Maximum Editability Максимальна редагованість + Maximum Editability Максимальні можливості редагування Measure Tool Інструмент «Вимірювання» Measurement Labels Мітки вимірів Media Носій @@ -1449,7 +1449,7 @@ Meters Метри Method: Метод: Millimeters Міліметри - Minify Мініфікувати + Minify Мінімізувати Minimal Мінімальний Minimize Згорнути Minus Back Мінус нижній @@ -1538,8 +1538,8 @@ New Graphic Style… Новий графічний стиль… New Layer Options… Параметри нового шару… New Layer… Новий шар… - New Opacity Masks Are Clipping Нові непрозорі маски обрізають - New Opacity Masks Are Inverted Нові непрозорі маски інвертуються + New Opacity Masks Are Clipping Нові маски непрозорості відсікають + New Opacity Masks Are Inverted Нові маски непрозорості інвертуються New Paragraph Style Новий стиль абзацу New Pattern Brush from Selection Новий візерунковий пензель з виділеного New PDF Preset Новий набір PDF @@ -1575,7 +1575,7 @@ No Document Немає документа No document open Немає відкритого документа No document open. Документ не відкрито. - No filter plug-ins installed Фільтруючі модулі не встановлені + No filter plug-ins installed Модулі фільтрів не встановлено No fonts are available. Шрифти недоступні. No gradient swatches Немає градієнтних зразків No graphic styles Немає графічних стилів @@ -1649,7 +1649,7 @@ Opacity Mask Info Відомості про маску непрозорості Opacity Mask Options Параметри маски непрозорості Opacity: Непрозорість: - Opacity: 50% Multiply Непрозорість: 50 % Збільшення + Opacity: 50% Multiply Непрозорість: 50 % Множення Opacity: Default Непрозорість: за замовчуванням Opacity: {value} Непрозорість: {value} Opaque Непрозорий @@ -1731,22 +1731,22 @@ Outline Stroke Обвести обведення Outline Text Обвести текст Outlines and Rules Контури та лінійки - Output Висновок + Output Виведення Output All Blacks Accurately Виводити всі чорні точно Output All Blacks as Rich Black Виводити всі чорні як складовий чорний Output Black Вихідний чорний - Output condition: Умова висновку: - Output Intent Призначення висновку - Output intent profile: Профіль призначення висновку: + Output condition: Умова виведення: + Output Intent Призначення виведення + Output intent profile: Профіль призначення виведення: Output White Вихідний білий - Output › Mode Висновок › Режим - Output: Висновок: + Output › Mode Виведення › Режим + Output: Виведення: Overlap: Перекриття: Overlapping Перекривається Overlay Накладання Overprint Накладення - Overprint and Transparency Flattener Накладання та поєднання прозоростей - Overprint and Transparency Flattener Options Параметри накладання та об’єднання прозоростей + Overprint and Transparency Flattener Накладання та зведення прозорості + Overprint and Transparency Flattener Options Параметри накладання та зведення прозорості Overprint Black… Накладення чорного… Overprint Fill Накладання заливки Overprint Preview Перегляд накладання @@ -1801,7 +1801,7 @@ Pattern Options Параметри візерунка Pattern options are available in pattern editing mode. Параметри візерунка доступні у режимі редагування візерунка. PDF PDF - PDF 1.3 files have no transparency: the preset flattens it. Файли PDF 1.3 не підтримують прозорість: набір поєднує її. + PDF 1.3 files have no transparency: the preset flattens it. Файли PDF 1.3 не підтримують прозорість: набір зводить її. PDF Info Відомості про PDF PDF Preset Набір PDF PDF Preset: Набір PDF: @@ -1933,10 +1933,10 @@ Profile: Профіль: Progressive Прогресивний Projecting Cap Закінчення, що виступає - Proof Colors Коліропроба + Proof Colors Пробні кольори Proof Setup Налаштування кольоропроби Properties Властивості - Proportional Oldstyle Пропорційні старий стиль + Proportional Oldstyle Пропорційні цифри старого стилю Pucker & Bloat… Зморщування та роздування … Pucker Tool Інструмент «Зморщування» Punctuation Пунктуація @@ -1950,7 +1950,7 @@ Quick Actions Швидкі дії Quickly start a new file Швидко створити новий файл Quit VectorCraft Вийти з VectorCraft - Radar Graph Tool Інструмент «Пелюсточна діаграма» + Radar Graph Tool Інструмент «Пелюсткова діаграма» Radial Радіальний Radial Glows Радіальні світіння Radial Gradient Радіальний градієнт @@ -2139,12 +2139,12 @@ Restored {} recovered document(s): save them to keep them Відновлено документів: {}: збережіть їх, щоб залишити Restrict printing, editing and other tasks Обмежити друк, редагування та інші завдання Reverse Front to Back Зворотний порядок спереду назад - Reverse Gradient Звернути градієнт + Reverse Gradient Змінити напрям градієнта Reverse Order Зворотний порядок - Reverse Path Direction Звернути напрям контуру - Reverse Path Direction Off Звернути напрям контуру вимкнено - Reverse Path Direction On Звернути напрямок контуру увімкнено - Reverse Spine Звернути кістяк + Reverse Path Direction Змінити напрям контуру + Reverse Path Direction Off Зміна напряму контуру вимкнена + Reverse Path Direction On Зміна напряму контуру увімкнена + Reverse Spine Змінити напрям осі Revert Повернути Revert to the saved version of “{name}”? Повернутися до збереженої версії «{name}»? Replace “{name}”, the file this document was opened from? Замінити «{name}» — файл, з якого відкрито цей документ? @@ -2168,7 +2168,7 @@ Rotate View Tool Інструмент «Поворот виду» Rotate… Повернути… Rotation Поворот - Roughen… Огрубління… + Roughen… Надати нерівності… Round Cap Кругле закінчення Round Corners… Заокруглити кути… Round Join Заокруглене з’єднання @@ -2238,8 +2238,8 @@ Save these settings as a print preset Зберегти ці параметри як набір друку Save to Swatches Зберегти у зразки Save To: Зберегти у: - Save Transparency Flattener Preset Зберегти набір об’єднання прозоростей - Save Transparency Flattener Preset… Зберегти набір об’єднання прозоростей… + Save Transparency Flattener Preset Зберегти набір зведення прозорості + Save Transparency Flattener Preset… Зберегти набір зведення прозорості… SAVED ЗБЕРЕЖЕНІ Saved Збережено Saved recovery data for {title} Збережено дані відновлення для {title} @@ -2338,14 +2338,14 @@ Select Anchors Виділити опорні точки Select Appearance Item Виділити елемент оформлення Select Art Виділити малюнок - Select art and choose Object › Pattern › Make. Перейдіть до малюнка та оберіть «Об’єкт › Візерунок › Створити». - Select art and click New Symbol to make one. Перейдіть до малюнка і натисніть «Новий символ», щоб створити його. + Select art and choose Object › Pattern › Make. Виділіть малюнок та виберіть «Об’єкт › Візерунок › Створити». + Select art and click New Symbol to make one. Виділіть малюнок і натисніть «Новий символ», щоб створити його. Select art and use New Brush to make one. Виділіть малюнок та використовуйте «Новий пензель», щоб створити його. Select Freeform Point Виділити довільну точку Select Gradient Stop Виділити точку переходу градієнта Select Guides Виділити напрямні Select Objects Виділити об’єкти - Select objects to see their CSS, or Generate CSS for the whole document. Перейдіть до об’єктів, щоб побачити CSS, або створіть CSS для всього документа. + Select objects to see their CSS, or Generate CSS for the whole document. Виділіть об’єкти, щоб побачити їхній CSS, або створіть CSS для всього документа. Select Same Tint % Виділити з тим же % відтінку Select Slices Виділити фрагменти Select styled art and click New Graphic Style. Виділіть оформлений малюнок та натисніть «Новий графічний стиль». @@ -2364,14 +2364,14 @@ Selection & Anchor Display Відображення виділення та опорних точок Selection Only Тільки виділення Selection Tool Інструмент «Виділення» - Send Backward На один рівень тому + Send Backward На один рівень назад Send to Back На задній план Send to Current Layer Надіслати на поточний шар Sentence case Як у реченні Separate colors into rows Розділити кольори по рядках Separate several keywords with commas. Розділяйте ключові слова комами. - Separations list the document's inks here: one page per ink that prints. Кольорові поділу перераховують тут фарби документа: одна сторінка на кожну фарбу, що друкується. - Separations Preview Перегляд кольороподілів + Separations list the document's inks here: one page per ink that prints. Тут перелічено фарби документа для кольороподілу: одна сторінка на кожну фарбу, що друкується. + Separations Preview Перегляд кольороподілу Serialize Document Серіалізувати документ Serif / Mincho Serif / Mincho Server-side (.map) Серверна частина (.map) @@ -2433,7 +2433,7 @@ Show Embedded Показати впроваджені Show Find Field Показати поле пошуку Show Font Names in English Показувати назви шрифтів англійською - Show Gradient Annotator Показати анотатор градієнта + Show Gradient Annotator Показати маркер градієнта Show Gradient Swatches Показати градієнтні зразки Show Grid Показати сітку Show Grid (⌘') Показати сітку (⌘') @@ -2660,7 +2660,7 @@ Use Library Item Використати елемент бібліотеки Graphic Графіка Show all open documents Показати всі відкриті документи - Symbol Instance Примірник символ + Symbol Instance Екземпляр символу Symbol Libraries Бібліотеки символів Symbol Libraries (on the roadmap) Бібліотеки символів (у плані розвитку) Symbol Screener Tool Інструмент «Прозорість символів» @@ -2679,7 +2679,7 @@ Tab Stops Табуляції Tabs Табуляції Tabular Lining Табличні цифри - Tabular Oldstyle Таблиці старого стилю + Tabular Oldstyle Табличні цифри старого стилю Target Об’єкт Target Contents Вміст об’єкта Target: Об’єкт: @@ -2688,7 +2688,7 @@ Temperature/Tint… Температура/відтінок… Template Шаблон Templates Шаблони - Templates Folder Шаблони папки + Templates Folder Папка шаблонів Templates… Шаблони… Text Displayed in Cell: Текст, що відображається в комірці: Text Export Options Параметри експорту тексту @@ -2773,12 +2773,12 @@ Transforms Трансформування Transform… Трансформувати… Transparency Прозорість - Transparency Flattener Presets Набори поєднання прозоростей - Transparency Flattener Presets… Набори поєднання прозоростей… + Transparency Flattener Presets Набори зведення прозорості + Transparency Flattener Presets… Набори зведення прозорості… Transparency Grid Сітка прозорості Transparency Info Відомості про прозорість - Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Прозорість поєднується з набором. Текст записується як контури; градієнти – як колірні смуги. - Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Прозорість поєднується з набором. Текст записується як контури; градієнти залишаються гладкими. + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Прозорість зводиться за параметрами набору. Текст записується як контури; градієнти — як колірні смуги. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Прозорість зводиться за параметрами набору. Текст записується як контури; градієнти залишаються гладкими. Transparency Options Параметри прозорості Transparent Прозорий Transparent (Default) Прозорий (за замовчуванням) @@ -2860,7 +2860,7 @@ Use document bleed settings Використовувати параметри вильоту документа Use Even-Odd Fill Rule Використовувати правило заливки «парне–непарне» Use Japanese Crop Marks Використовувати японські мітки обрізання - Use Low Resolution Proxy for Linked EPS Використовувати низькодозволений проксі для пов’язаних EPS + Use Low Resolution Proxy for Linked EPS Використовувати попередній перегляд низької роздільної здатності для пов’язаних EPS Use Non-Zero Winding Fill Rule Використовувати правило заливки «ненульове заповнення» Use Precise Cursors Використовувати точні курсори Use Pressure Pen Використовувати перо з натиском @@ -3099,15 +3099,15 @@ Merged PRs Об’єднані PR Commits Коміти Lines added Додані рядки - Lines deleted Віддалені рядки + Lines deleted Видалені рядки Line delta Різниця рядків Binary assets added Додані бінарні ресурси - Binary assets removed Віддалені бінарні ресурси + Binary assets removed Видалені бінарні ресурси First commit Перший коміт Last commit Останній коміт First Перший Last Останній - {people} contributors · {commits} commits {people} учасників · {commits} коммітів + {people} contributors · {commits} commits {people} учасників · {commits} комітів No contributor data was built into this copy. Ця копія не містить даних про учасників. No model credits were built into this copy. Ця копія не містить відомостей про моделі. Company Компанія @@ -3123,15 +3123,15 @@ axis Horizontal Горизонтальна axis Vertical Вертикальна axis Both Обидві Drag to move Перетягніть для переміщення - Drag to float as a toolbar Перетягніть, щоб зробити плаваючою панеллю інструментів + Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів Put back in the toolbar Повернути на панель інструментів - Float Panel Зробити панель плаваючою + Float Panel Від’єднати панель Dock Panel Прикріпити панель - Drag to float the panel group Перетягніть, щоб зробити групу панелей плаваючою + Drag to float the panel group Перетягніть, щоб від’єднати групу панелей Put back in the dock Повернути у док - Drag to float the toolbar Перетягніть, щоб зробити панель інструментів плаваючою + Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення - Float Tool Group Зробити групу інструментів плаваючою + Float Tool Group Від’єднати групу інструментів @msg no library `{id}` немає бібліотеки `{id}` @msg select some type first спочатку виділіть текст @msg {_1} has no {_2} style {_1} не має накреслення {_2} @@ -3153,7 +3153,7 @@ axis Both Обидві @msg Combine Slices: the slices have no bounds Об’єднання фрагментів: фрагменти не мають кордонів @msg Convert to Artboards: selection has no area Перетворення на монтажні області: виділення не має області @msg Convert to Shape: no rectangles or ellipses selected Перетворення на фігуру: не виділено прямокутники або еліпси -@msg Copied the CSS of {rules} object(s) Скопійовано CSS для {rules} об’єкта(ів) +@msg Copied the CSS of {rules} object(s) Скопійовано CSS об’єктів: {rules} @msg Corners edits the corners of a path «Кути» редагує кути контуру @msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e} @msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e} @@ -3176,8 +3176,8 @@ axis Both Обидві @msg Expand: nothing to expand Розбір: нема чого розбирати @msg Exported {count} file(s) to {folder} Експортовано файли: {count} до {folder} @msg Exported {path} Експортовано: {path} -@msg Flatten Transparency: select objects to flatten Об’єднання прозоростей: виділіть об’єкти для об’єднання -@msg Flatten Transparency: {e} Об’єднання прозоростей: {e} +@msg Flatten Transparency: select objects to flatten Зведення прозорості: виділіть об’єкти для зведення +@msg Flatten Transparency: {e} Зведення прозорості: {e} @msg Image Trace: the traced image is missing Трасування зображення: зображення, що трасується, відсутнє @msg Imported {what}: {_1} Імпортовано: {what}: {_1} @msg Installed plug-in {_1} Встановлено модуль {_1} @@ -3329,7 +3329,7 @@ axis Both Обидві @msg image would be too large; lower the resolution зображення буде завеликим; зменште роздільну здатність @msg index must be a number or first, previous, next or last index має бути числом або first, previous, next, last @msg index must be a number or first, previous, next or last, not `{other}` index повинен бути числом або first, previous, next, last, а не `{other}` -@msg installing from a path is not available on the web; pass the module as `dataBase64` встановлення з файла недоступне у вебверсії; передайте модуль як `dataBase64` +@msg installing from a path is not available on the web; pass the module as `dataBase64` встановлення з файлу недоступне у вебверсії; передайте модуль як `dataBase64` @msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) внутрішня помилка `{cmd}`: {msg} (документ залишений у попередньому стані; будь ласка, повідомте про цю помилку) @msg internal error: {msg} (please report this bug) внутрішня помилка: {msg} (будь ласка, повідомте про цю помилку) @msg invalid PDF setting: {0} неприпустимий параметр PDF: {0} @@ -3475,7 +3475,7 @@ axis Both Обидві @msg plug-in exceeded its {0} модуль перевищив свій {0} @msg plug-in failed: {0} модуль завершився помилково: {0} @msg plug-in manifest: {0} маніфест модуля: {0} -@msg plug-in output: {0} висновок модуля: {0} +@msg plug-in output: {0} виведення модуля: {0} @msg plug-in parameters: {0} параметри модуля: {0} @msg plug-in trapped: {0} модуль зупинено з помилкою: {0} @msg print settings are not an object параметри друку не є об’єктом @@ -3500,9 +3500,9 @@ axis Both Обидві @msg select a symbol instance виділіть екземпляр символу @msg select an Image Trace object виділіть об’єкт трасування зображення @msg could not decode image: {0} не вдалося декодувати зображення: {0} -@msg image is empty зображення порожньо +@msg image is empty зображення порожнє @msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) зображення має розмір {width} × {height} пікселів, що більше, ніж приймає трасування зображення ({max} мегапікселів) -@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільшіть «Шум», зменшіть «Контури» або «Кольори» або виберіть набір з меншою точністю +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільште «Шум», зменште «Контури» або «Кольори» або виберіть набір з меншою точністю @msg select an envelope виділіть оболонку @msg select an image виділіть зображення @msg select an object in perspective виділіть об’єкт у перспективі @@ -3664,7 +3664,7 @@ axis Both Обидві @msg A browser with WebGPU or WebGL2 is required. Потрібен браузер із WebGPU або WebGL2. @msg The graphics were lost and couldn't be restarted. Графіку було втрачено, і її не вдалося перезапустити. @msg Your unsaved changes are kept in this browser: reload the page to get them back. Ваші незбережені зміни зберігаються у цьому браузері: перезавантажте сторінку, щоб повернути їх. -@msg Reload the page to go on (changes not saved yet may be lost). Перезавантажте сторінку, щоб продовжити. +@msg Reload the page to go on (changes not saved yet may be lost). Перезавантажте сторінку, щоб продовжити (незбережені зміни можуть бути втрачені). @msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту) @msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів @msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі @@ -3695,7 +3695,7 @@ axis Both Обидві Settings Налаштування Break Link Розірвати зв’язок On: one locked object showing the document, updated when it changes. Увімк.: один заблокований об’єкт, який показує документ і оновлюється під час його зміни. - Off: an editable copy of its art, embedded in this document. Вимк.: редагована копія зображення, введена в цей документ. + Off: an editable copy of its art, embedded in this document. Вимк.: редагована копія графіки, вбудована в цей документ. Images: link to the file instead of only embedding it. Зображення: пов’язувати із файлом замість простого впровадження. VectorCraft documents: one locked object, updated when the file changes. Документи VectorCraft: один заблокований об’єкт, який оновлюється під час зміни файлу. A file dialog is open: choose there first Відкрито вікно вибору файлу: спочатку зробіть вибір у ньому From cee9feea2e8df144db3095edc76c0b8c4df8f1f9 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 02:53:07 +0900 Subject: [PATCH 16/43] i18n: Japanese translations for error and status-bar messages Japanese now translates the status-bar and error messages (`@msg` templates) like Spanish, French, Italian, Russian and Ukrainian do: 556 rows at the end of ja.tsv, the 498 message literals the test scan finds plus the 58 that the other catalogs also carry (web start-up, relinking, export notes, PostScript read errors). `ja` joins COMPLETE_MESSAGES, so complete_languages_translate_every_message keeps it complete. The registry comment, docs/development.md and the ROADMAP line now list Japanese with the other five. The messages themselves stay English for agents and MCP. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n/ja.tsv | 566 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 2 +- docs/development.md | 4 +- 5 files changed, 571 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 466695c1..1dd10047 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,7 +146,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). -- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f1b09708..7b17ee3a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6,7 +6,8 @@ # Japanese localisation was copied. Toggles read as their action (「…を表示」「…を隠す」). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# tested) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, tested). +# Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. About VectorCraft VectorCraft について All Scripts すべての文字 @@ -3153,3 +3154,566 @@ theme Appearance 外観 Draft ドラフト Good 標準 Best 高品質 + +# Status and error messages (`@msg`), translated where the status bar shows them; the message itself +# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order +# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +# Japanese: no final 。 (these are status lines, and reasons that follow a colon), unless the English ends +# with a period; a space between Japanese text and an adjacent Latin word, number or {placeholder}. +@msg Reset {_1} {_1} をリセットしました +@msg Saving 保存中 +@msg Saving {_1} {_1} を保存中 +@msg Sent to {_1} {_1} に送信しました +@msg `to` must be "corner" or "smooth" `to` には "corner" または "smooth" を指定してください +@msg a layer couldn't be read 読み込めなかったレイヤーがあります +@msg it has more than one artboard アートボードが複数あります +@msg its editing data has no layers 編集データにレイヤーがありません +@msg its editing data is damaged 編集データが破損しています +@msg {_1} files {_1} 個のファイル +@msg A browser with WebGPU or WebGL2 is required. WebGPU または WebGL2 に対応したブラウザーが必要です。 +@msg Loading VectorCraft… VectorCraft を読み込んでいます… +@msg Reload the page to go on (changes not saved yet may be lost). 続けるにはページを再読み込みしてください (未保存の変更は失われる場合があります)。 +@msg The graphics were lost and couldn't be restarted. グラフィックス機能が利用できなくなり、再起動できませんでした。 +@msg The graphics were lost and have been restarted グラフィックス機能が利用できなくなったため、再起動しました +@msg VectorCraft failed to start: {e} VectorCraft を起動できませんでした: {e} +@msg Your unsaved changes are kept in this browser: reload the page to get them back. 未保存の変更はこのブラウザーに残っています: 復元するにはページを再読み込みしてください。 +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 最初に試したグラフィックスプロセッサーではウィンドウを表示できなかったため、{summary} で VectorCraft を再起動しました +@msg not a DXF drawing DXF 図面ではありません +@msg scale must be a number from {_1} to {_2}, not {s} 縮尺は {_1} から {_2} までの数値にしてください ({s} は指定できません) +@msg the artboard to fit must be a positive size up to {_1} pt 合わせる先のアートボードのサイズは 0 より大きく {_1} pt 以下にしてください +@msg the drawing has no layout `{_1}` (it has {_2}) 図面にレイアウト `{_1}` はありません (含まれるレイアウト: {_2}) +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 図面は読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard この縮尺では図面が {w} × {h} pt になり、カンバスに収まりません: 縮尺を小さくするか、アートボードに合わせてください +@msg the scale is not a usable number 縮尺が有効な数値ではありません +@msg the drawing is too large to open 図面が大きすぎて開けません +@msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません) +@msg image '{key}' has no pixels and was left out 画像「{key}」はピクセルがないため省略しました +@msg Embedded {n} image(s) 画像を {n} 枚埋め込みました +@msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません +@msg range `{s}` is empty 範囲 `{s}` が空です +@msg range `{s}`: `{part}` names no number 範囲 `{s}`: `{part}` に番号が指定されていません +@msg range `{s}`: `{part}` runs backwards 範囲 `{s}`: `{part}` が逆順になっています +@msg range `{s}`: `{t}` is not a number from 1 to {count} 範囲 `{s}`: `{t}` は 1 から {count} までの数値ではありません +@msg not a graphic style library (format `{_1}`) グラフィックスタイルライブラリではありません (形式: `{_1}`) +@msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません +@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません +@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません +@msg no symbol instances selected シンボルインスタンスが選択されていません +@msg select a symbol instance シンボルインスタンスを選択してください +@msg the art has no bounds アートに境界がありません +@msg Image Trace: the traced image is missing 画像トレース: トレース元の画像が見つかりません +@msg Live Paint Merge: select a Live Paint group and the paths to add ライブペイントの結合: ライブペイントグループと、追加するパスを選択してください +@msg Live Paint: select paths or compound paths ライブペイント: パスまたは複合パスを選択してください +@msg Live Paint: these paths are too degenerate to paint ライブペイント: これらのパスは形状がつぶれすぎていてペイントできません +@msg Shape Builder: no region under the pointer シェイプ形成ツール: ポインターの下に領域がありません +@msg Shape Builder: select paths or compound paths シェイプ形成ツール: パスまたは複合パスを選択してください +@msg image data `{_1}` is missing 画像データ `{_1}` が見つかりません +@msg no Live Paint edge near that point その位置の近くにライブペイントのエッジがありません +@msg no Live Paint face at that point その位置にライブペイントの面がありません +@msg select a Live Paint group ライブペイントグループを選択してください +@msg select an Image Trace object 画像トレースオブジェクトを選択してください +@msg select objects, not layers レイヤーではなくオブジェクトを選択してください +@msg Blend colors: select at least three objects with filled colours カラーのブレンド: カラーの塗りがあるオブジェクトを 3 つ以上選択してください +@msg no saved themes 保存したテーマはありません +@msg `{keep}` is not a side of this axis `{keep}` はこの軸のどちら側でもありません +@msg unknown side `{other}` (left|right|top|bottom) `{other}` は不明な側の指定です (left|right|top|bottom) +@msg an object in perspective would leave the edited grid 遠近グリッド上のオブジェクトが編集後のグリッドの外に出てしまいます +@msg nothing to warp ワープするものがありません +@msg select an object in perspective 遠近グリッド上のオブジェクトを選択してください +@msg select the artwork to warp ワープするアートワークを選択してください +@msg select the objects to transform in perspective 遠近グリッド上で変形するオブジェクトを選択してください +@msg select type in perspective 遠近グリッド上のテキストを選択してください +@msg the object has no geometry オブジェクトに形状がありません +@msg the object has no stroke オブジェクトに線がありません +@msg the object is beyond the plane's horizon オブジェクトが面の水平線を越えた位置にあります +@msg the object isn't in perspective オブジェクトが遠近グリッド上にありません +@msg the object isn't on a perspective plane オブジェクトが遠近グリッドの面上にありません +@msg the object would cross the horizon of the perspective plane オブジェクトが遠近グリッドの面の水平線をまたいでしまいます +@msg the objects would leave the grid オブジェクトがグリッドの外に出てしまいます +@msg the pointer is beyond the plane's horizon ポインターが面の水平線を越えた位置にあります +@msg the selection isn't in perspective 選択範囲が遠近グリッド上にありません +@msg the stroke has no weight 線に線幅がありません +@msg {command} didn't create an object {command} でオブジェクトが作成されませんでした +@msg Make Guides: select paths ガイドを作成: パスを選択してください +@msg Release Guides: no guides ガイドを解除: ガイドがありません +@msg guides are locked ガイドがロックされています +@msg no saved selections 保存した選択範囲はありません +@msg no such artboard 指定されたアートボードが見つかりません +@msg cannot split an open path at its end point オープンパスは端点で分割できません +@msg empty brush stroke ブラシストロークが空です +@msg no such anchor 指定されたアンカーポイントが見つかりません +@msg no such segment 指定されたセグメントが見つかりません +@msg no such subpath 指定されたサブパスが見つかりません +@msg object {id} is not a path オブジェクト {id} はパスではありません +@msg nothing to redo やり直す操作がありません +@msg nothing to undo 取り消す操作がありません +@msg Expand Appearance: the selection has a basic appearance アピアランスを分割: 選択範囲は基本アピアランスです +@msg select (or target) objects whose appearance isn't basic 基本アピアランスではないオブジェクトを選択するか、ターゲットに指定してください +@msg {C}: no effect at index {from} {C}: インデックス {from} に効果がありません +@msg Expand: no gradient fills 拡張: グラデーションの塗りがありません +@msg Expand: nothing to expand 拡張: 拡張するものがありません +@msg batch step {_1} (`{id}`) failed: {e} バッチのステップ {_1} (`{id}`) が失敗しました: {e} +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP ファイルが対応するのは RGB とグレースケールのみです: colorModel には rgb または gray を指定してください +@msg Targa files are RGB: colorModel rgb Targa ファイルが対応するのは RGB のみです: colorModel には rgb を指定してください +@msg antiAlias `{id}`: none, art or type antiAlias に `{id}` は指定できません: none、art、type のいずれかを指定してください +@msg background {other}: transparent, white, black or a colour such as "#ff8800" 背景に {other} は指定できません: transparent、white、black、または「#ff8800」のようなカラーを指定してください +@msg no artboard {i} (0-based; the document has {count}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {count} 個あります) +@msg no artboards named アートボードが指定されていません +@msg the document has no artboard ドキュメントにアートボードがありません +@msg this format holds one artboard: export several as PDF or with document.exportForScreens この形式で書き出せるアートボードは 1 つだけです: 複数のアートボードは PDF として書き出すか、document.exportForScreens を使用してください +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) このファイルは PDF 互換性なしで保存されており、編集データを読み込めません ({why}) +@msg the export wrote no file 書き出しでファイルが作成されませんでした +@msg {_1} files can be opened but not written (see document.formats) {_1} ファイルは開けますが、保存や書き出しはできません (document.formats を参照してください) +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: この環境にはファイルシステムがありません (開くには dataBase64 を渡し、dataBase64 を受け取るにはパスを省略してください) +@msg Save writes {_1}, not {_2}: use document.export for other formats 保存できる形式は {_1} のみで、{_2} 形式では保存できません: ほかの形式には document.export を使用してください +@msg no changes since the last save 前回の保存以降、変更はありません +@msg unknown format `{f}` (Save writes {_1}) `{f}` は不明な形式です (保存できる形式: {_1}) +@msg `svg` must be an object of SVG options (see document.formats) `svg` には SVG オプションのオブジェクトを指定してください (document.formats を参照) +@msg no end record 終端レコードがありません +@msg not a stored central record 無圧縮のセントラルディレクトリレコードではありません +@msg truncated データが途中で切れています +@msg {name}: CRC mismatch {name}: CRC が一致しません +@msg {name}: no local header {name}: ローカルヘッダーがありません +@msg Flatten Transparency: select objects to flatten 透明部分を分割・統合: 分割・統合するオブジェクトを選択してください +@msg Flatten Transparency: {e} 透明部分を分割・統合: {e} +@msg balance must be between 0 and 100 バランスは 0 から 100 までの値にしてください +@msg resolutions must be between 1 and 2400 ppi 解像度は 1 から 2400 ppi までの値にしてください +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) `{name}` は不明なプリセットです (high、medium、low、または保存したプリセット: flattener.presets.list を参照してください) +@msg `at` must be [x, y] `at` は [x, y] の形式で指定してください +@msg `color` must be a colour `color` にはカラーを指定してください +@msg `freeform.points` must be an array `freeform.points` には配列を指定してください +@msg `{key}` must be a number `{key}` には数値を指定してください +@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です +@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です +@msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です) +@msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません +@msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines) +@msg `aspect` must be a number (%) `aspect` には数値 (%) を指定してください +@msg `gradient` must be an object `gradient` にはオブジェクトを指定してください +@msg `mode` applies to freeform gradients `mode` はフリーフォームグラデーションにのみ適用されます +@msg `stops` must be an array `stops` には配列を指定してください +@msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください +@msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です +@msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください +@msg stop {i} needs `offset` 分岐点 {i} には `offset` が必要です +@msg stop {i} needs a valid `color` 分岐点 {i} には有効な `color` が必要です +@msg stop {i}: `{k}` must be a number 分岐点 {i}: `{k}` には数値を指定してください +@msg unknown gradient kind `{k}` (linear, radial, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、freeform) +@msg the art could not be sampled アートをサンプリングできませんでした +@msg the art is too light for any dots アートが明るすぎて網点を生成できません +@msg the document has no symbols ドキュメントにシンボルがありません +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` はカラーではありません (#rrggbb またはプリセット名で指定してください) +@msg a colour index is 0 to {_1}, not {n} カラーのインデックスは 0 から {_1} までにしてください ({n} は指定できません) +@msg a colour is an index, #rrggbb or a preset name, not {v} カラーはインデックス、#rrggbb、プリセット名のいずれかで指定してください ({v} は指定できません) +@msg a document needs at least one layer ドキュメントには少なくとも 1 つのレイヤーが必要です +@msg no artwork アートワークがありません +@msg no selection bounds 選択範囲に境界がありません +@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません +@msg the layer or group is locked レイヤーまたはグループがロックされています +@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています +@msg there are no objects to release レイヤーに分配するオブジェクトがありません +@msg no library `{id}` ライブラリ `{id}` が見つかりません +@msg {path}: {e} {path}: {e} +@msg can't write .{ext} images .{ext} 画像は書き出せません +@msg Replace Spine: select a blend and a path ブレンド軸を置き換え: ブレンドとパスを選択してください +@msg nothing to wrap まとめるものがありません +@msg select a blend ブレンドを選択してください +@msg select a gradient mesh グラデーションメッシュを選択してください +@msg the object has no opacity mask オブジェクトに不透明マスクがありません +@msg Convert to Artboards: selection has no area アートボードに変換: 選択範囲に面積がありません +@msg Convert to Shape: no rectangles or ellipses selected シェイプに変換: 長方形または楕円形が選択されていません +@msg image would be too large; lower the resolution 画像が大きくなりすぎるため、解像度を下げてください +@msg clipboard is empty クリップボードが空です +@msg no anchor points selected アンカーポイントが選択されていません +@msg select at least two objects オブジェクトを 2 つ以上選択してください +@msg no saved presets 保存したプリセットはありません +@msg compound paths can only contain paths 複合パスに含められるのはパスだけです +@msg no bounds 境界がありません +@msg no current layer 現在のレイヤーがありません +@msg no key object キーオブジェクトが設定されていません +@msg no previous transform 前回の変形がありません +@msg nothing to align 整列するものがありません +@msg select a clip group クリップグループを選択してください +@msg select two or more objects to distribute 分布させるオブジェクトを 2 つ以上選択してください +@msg selection has no bounds 選択範囲に境界がありません +@msg the top object must be a path, compound path or text object to use as a clipping mask クリッピングマスクとして使うには、最前面のオブジェクトがパス、複合パス、テキストオブジェクトのいずれかである必要があります +@msg no target object has an opacity mask: select one or give `id` 不透明マスクのあるターゲットオブジェクトがありません: 該当するオブジェクトを選択するか、`id` を指定してください +@msg the object already has an opacity mask オブジェクトにはすでに不透明マスクがあります +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` はプロセスカラーです: 濃淡を適用できるのはグローバルカラーと特色だけです +@msg `{name}` isn't a colour swatch `{name}` はカラースウォッチではありません +@msg a document needs at least one artboard ドキュメントには少なくとも 1 つのアートボードが必要です +@msg empty path パスが空です +@msg join needs open paths 連結にはオープンパスが必要です +@msg path is already closed パスはすでに閉じています +@msg select paths to join 連結するパスを選択してください +@msg direct-select anchor points to cut at (not the end points of open paths) ダイレクト選択ツールで、カットする位置のアンカーポイントを選択してください (オープンパスの端点を除く) +@msg select paths or anchor points パスまたはアンカーポイントを選択してください +@msg Divide Objects Below: select a path 背面のオブジェクトで型抜き: パスを選択してください +@msg Divide Objects Below: select exactly one path 背面のオブジェクトで型抜き: パスを 1 つだけ選択してください +@msg Offset Path: select paths パスのオフセット: パスを選択してください +@msg Outline Stroke: no stroked paths selected パスのアウトライン: 線のあるパスが選択されていません +@msg Split Into Grid: select paths large enough for the grid グリッドに分割: グリッドが収まる大きさのパスを選択してください +@msg empty cutter 型抜きに使うパスが空です +@msg paths contain invalid coordinates パスに無効な座標が含まれています +@msg {label} produced an empty result {label} の結果が空になりました +@msg {label}: lost track of a shape's style {label}: シェイプのスタイル情報が失われました +@msg {label}: select paths {label}: パスを選択してください +@msg {label}: select paths, compound paths, groups or text {label}: パス、複合パス、グループ、テキストのいずれかを選択してください +@msg already editing a pattern すでにパターンを編集中です +@msg no pattern `{name}` パターン `{name}` が見つかりません +@msg not in pattern editing mode パターン編集モードではありません +@msg pattern missing パターンが見つかりません +@msg select a repeat リピートを選択してください +@msg select the art to repeat リピートするアートを選択してください +@msg the document has no patterns ドキュメントにパターンがありません +@msg no artboard アートボードがありません +@msg installing from a path is not available on the web; pass the module as `dataBase64` Web 版ではファイルパスからはインストールできないため、モジュールは `dataBase64` で渡してください +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` は `auto` か、`en` や `zh-hant` などの言語コードにしてください +@msg `{key}` must be a #rrggbb colour `{key}` は #rrggbb 形式のカラーにしてください +@msg `{key}` must be a string `{key}` は文字列にしてください +@msg `{key}` must be a whole number `{key}` は整数にしてください +@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` は {min} から {max} までの整数にしてください (指定値: {x}) +@msg `{key}` must be between {min} and {max} (got {x}) `{key}` は {min} から {max} までの値にしてください (指定値: {x}) +@msg `{key}` must be one of {_1} `{key}` は {_1} のいずれかにしてください +@msg unknown preference `{key}` `{key}` は不明な環境設定の項目です +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) データ復帰のコピーを保存する場所がありません: 環境設定 → ファイル管理 → データ復帰 → フォルダー (recoveryFolder) を設定してください +@msg `{area}` is no recovery area name `{area}` は復帰用領域の名前として使えません +@msg `{name}` is no recovery entry name `{name}` は復帰用エントリの名前として使えません +@msg no clock to keep a heartbeat by ハートビートの記録に必要な時刻を取得できません +@msg no free recovery area 空いている復帰用領域がありません +@msg no native format ネイティブ形式がありません +@msg nothing encoded 何もエンコードされませんでした +@msg recovery copy `{file}`: {e} 復帰用コピー `{file}` を読み込めませんでした: {e} +@msg {name}: no such recovery entry {name}: 復帰用エントリが見つかりません +@msg Combine Slices: the slices have no bounds スライスを結合: スライスに境界がありません +@msg Create from Guides: the document has no artboard ガイドから作成: ドキュメントにアートボードがありません +@msg Create from Guides: the guides make more than {MAX_PIECES} slices ガイドから作成: ガイドで区切るとスライスが {MAX_PIECES} 個を超えます +@msg Create from Selection: the selection has no area 選択範囲から作成: 選択範囲に面積がありません +@msg Release: no layer to put the rectangle in 解除: 長方形を追加するレイヤーがありません +@msg a slice size must be a positive number of points スライスのサイズはポイント単位の正の数にしてください +@msg divide into 1 to {MAX_PIECES} slices スライスの分割数は 1 から {MAX_PIECES} までにしてください +@msg no slice selected スライスが選択されていません +@msg the document has no slices ドキュメントにスライスがありません +@msg no saved profiles 保存したプロファイルはありません +@msg select a stroke with a variable width 可変幅の線を選択してください +@msg the selected stroke's profile is already listed as {_1} 選択した線のプロファイルは、すでに {_1} として登録されています +@msg a graphic style named `{to}` already exists `{to}` という名前のグラフィックスタイルはすでに存在します +@msg no graphic style `{name}` グラフィックスタイル `{name}` が見つかりません +@msg node {_1} is not text ノード {_1} はテキストではありません +@msg Text Wrap: select a wrap object テキストの回り込み: 回り込みオブジェクトを選択してください +@msg layers can't be wrap objects レイヤーは回り込みオブジェクトにできません +@msg no threaded text selected スレッドテキストが選択されていません +@msg Create Outlines: select text objects アウトラインを作成: テキストオブジェクトを選択してください +@msg paste unavailable 貼り付けを使用できません +@msg Save for Web: the image is empty Web 用に保存: 画像が空です +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` は「#ff8800」のような形式のカラーではありません +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: 指定できるカラーは {MAX_TABLE} 色までです +@msg no artboard {i} (0-based; the document has {_1}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {_1} 個あります) +@msg no selected slice in the image: select slices, or save all of them 画像内に選択されたスライスがありません: スライスを選択するか、すべてのスライスを保存してください +@msg nothing to save: the document has no visible art 保存するものがありません: ドキュメントに表示されているアートがありません +@msg percent must be above 0 and at most 6400, not {p} パーセントは 0 より大きく 6400 以下にしてください ({p} は指定できません) +@msg unknown preset `{name}` (see webExport.presets.list) `{name}` は不明なプリセットです (webExport.presets.list を参照してください) +@msg width and height are at least 1 pixel 幅と高さは 1 ピクセル以上にしてください +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` は 1、3、5 のいずれかにしてください +@msg `{path}` must be a bool or an object of bools `{path}` はブール値、またはブール値からなるオブジェクトにしてください +@msg cannot distort a zero-size bounding box サイズが 0 のバウンディングボックスは変形できません +@msg nothing to distort 変形するものがありません +@msg unknown option `{path}.{k}` `{path}.{k}` は不明なオプションです +@msg command `{0}` is not available right now: {1} コマンド `{0}` は現在使用できません: {1} +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` で内部エラーが発生しました: {msg} (ドキュメントは変更されていません。このバグを報告してください) +@msg invalid parameters for `{cmd}`: {msg} `{cmd}` のパラメーターが正しくありません: {msg} +@msg no active document アクティブなドキュメントがありません +@msg no interaction in progress 進行中の操作がありません +@msg no such object {0} オブジェクト {0} が見つかりません +@msg result would exceed the canvas (coordinates out of range) 結果がカンバスの範囲を超えます (座標が範囲外です) +@msg unknown command `{0}` `{0}` は不明なコマンドです +@msg `{what}` closes nothing `{what}` に対応する開始部分がありません +@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています +@msg a path has too many points ポイントが多すぎるパスがあります +@msg it ends inside a layer レイヤーの途中で終わっています +@msg it has art outside its layers レイヤーの外にアートがあります +@msg it has more than {MAX_NODES} objects オブジェクトが {MAX_NODES} 個を超えています +@msg it has no layers レイヤーがありません +@msg it has too many values in a row 連続する値が多すぎます +@msg it has {_1}, which VectorCraft doesn't read from it yet {_1} が含まれていますが、VectorCraft はまだこのファイルからは読み込めません +@msg its objects are nested too deeply オブジェクトの入れ子が深すぎます +@msg its section ending with `%{end}` doesn't end `%{end}` で終わるはずのセクションが終わっていません +@msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました +@msg an image whose data is cut short was left out データが途中で切れている画像は除外されました +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています +@msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました +@msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています +@msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています +@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください +@msg this is not a PostScript file PostScript ファイルではありません +@msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name}) +@msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}) +@msg a PostScript error ({name} in `{op}`: {at}) PostScript エラー (`{op}` での {name}: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}: {at}) +@msg a PostScript error ({name}) PostScript エラー ({name}) +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft の PostScript 読み込み機能が対応していない `{at}` を使用しています +@msg the file was read up to an error ({why}): what follows it was left out ファイルはエラーが発生した箇所まで読み込まれました ({why}): それ以降の内容は除外されました +@msg this file's PostScript couldn't be read ({why}): {what} このファイルの PostScript を読み込めませんでした ({why}): {what} +@msg it has no page ページがありません +@msg it has too many text objects テキストオブジェクトが多すぎます +@msg it has too much text テキストが多すぎます +@msg its editing data doesn't say where its art is 編集データにアートの位置が記録されていません +@msg its editing data doesn't say where its artboard is 編集データにアートボードの位置が記録されていません +@msg its editing data is for a different page 編集データが別のページのものです +@msg its page has text, and its layers have no text objects to put it in ページにテキストがありますが、レイヤーにはそれを入れるテキストオブジェクトがありません +@msg the art on them differs from the page's in {_1}% of it レイヤー上のアートの {_1}% がページのアートと異なります +@msg its Windows metafile preview was opened instead, which may simplify the art 代わりに Windows メタファイルのプレビューを開いたため、アートが簡略化されている場合があります +@msg its preview was placed instead, at screen resolution 代わりにプレビューを画面解像度で配置しました +@msg shadings of more than 4096 patches or triangles were left out パッチまたは三角形が 4096 個を超えるシェーディングは除外されました +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} {procs} 内で、VectorCraft の PostScript 読み込み機能が対応していない `{at}` を使用しています +@msg the export region is not finite 書き出し範囲に無効な数値が含まれています +@msg a page out of range 範囲外のページがあります +@msg a page's placement is not finite ページの配置に無効な数値が含まれています +@msg nothing to print プリントするものがありません +@msg page size {w} × {h} ページサイズ {w} × {h} は無効です +@msg the preview image is empty プレビュー画像が空です +@msg the thumbnail image is malformed サムネール画像の形式が正しくありません +@msg can't encode the document: {0} ドキュメントをエンコードできません: {0} +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} バージョン {0} では保存できません: VectorCraft で保存できるのはバージョン {MIN_VERSION} から {VERSION} までです +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) ファイルのバージョン {0} は、この VectorCraft が対応するバージョン ({VERSION}) より新しいものです +@msg invalid image data for `{0}` `{0}` の画像データが正しくありません +@msg not a VectorCraft file: {0} VectorCraft ファイルではありません: {0} +@msg the compressed file unpacks to more than {_1} MB 圧縮ファイルを展開すると {_1} MB を超えます +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files バージョン {0} のファイルは圧縮できません: 圧縮ファイルを開けるのはバージョン {COMPRESSED_SINCE} 以降のみです +@msg a bitmap is empty ビットマップが空です +@msg a bitmap is too large to read ビットマップが大きすぎて読み込めません +@msg compressed (RLE) bitmaps can't be read yet 圧縮 (RLE) されたビットマップはまだ読み込めません +@msg the image's pixels don't match its size 画像のピクセル数がサイズと一致しません +@msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません +@msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません +@msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません +@msg the EMF header is damaged EMF ヘッダーが破損しています +@msg damaged 破損しています +@msg the WMF header is damaged WMF ヘッダーが破損しています +@msg the export region is empty 書き出し範囲が空です +@msg a stream runs past the end ストリームがファイルの末尾を超えています +@msg a stream's length is wrong ストリームの長さが正しくありません +@msg a stream's length isn't a number ストリームの長さが数値ではありません +@msg bad stream length ストリームの長さが無効です +@msg PDF writer error: {0} PDF の書き出し中にエラーが発生しました: {0} +@msg artboard {0} does not exist アートボード {0} は存在しません +@msg cannot read PDF: {0} PDF を読み込めません: {0} +@msg invalid PDF setting: {0} PDF の設定が正しくありません: {0} +@msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です) +@msg the PDF has no pages PDF にページがありません +@msg the PDF is password-protected: give its password この PDF はパスワードで保護されています: パスワードを入力してください +@msg the PDF password is wrong PDF のパスワードが正しくありません +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG ファイルには仮のページだけが含まれ、アートは含まれていません (PDF 互換なしで保存されています): PDF 互換をオンにして保存し直すか、PDF または SVG として保存してください +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG これは PostScript ファイル (古い形式または PDF 互換なしで保存された .ai、または EPS ファイル) のため、まだ開けません: PDF、PDF 互換の .ai、または SVG として保存してください +@msg unsupported: {0} 対応していません: {0} +@msg a page is missing 見つからないページがあります +@msg a page tree kid isn't a reference ページツリーの子要素が参照になっていません +@msg a page tree node has no kids ページツリーのノードに子要素がありません +@msg an object can't be read 読み込めないオブジェクトがあります +@msg bad page tree ページツリーが無効です +@msg bad trailer トレーラーが無効です +@msg no catalog カタログがありません +@msg no cross-reference table 相互参照テーブルがありません +@msg no pages ページがありません +@msg no plug-in {0} is installed プラグイン {0} はインストールされていません +@msg not a valid plug-in module: {0} 有効なプラグインモジュールではありません: {0} +@msg plug-in ABI: {0} プラグインの ABI に問題があります: {0} +@msg plug-in exceeded its {0} プラグインが {0} を超えました +@msg plug-in failed: {0} プラグインの実行に失敗しました: {0} +@msg plug-in manifest: {0} プラグインのマニフェストに問題があります: {0} +@msg plug-in output: {0} プラグインの出力に問題があります: {0} +@msg plug-in parameters: {0} プラグインのパラメーターに問題があります: {0} +@msg plug-in trapped: {0} プラグインが異常終了しました: {0} +@msg is not an image VectorCraft reads VectorCraft で読み込める画像ではありません +@msg not found 見つかりません +@msg SVG parse error: {0} SVG を解析できませんでした: {0} +@msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3}) +@msg Exporting {_1} {_1} を書き出し中 +@msg Exported {what} {what} を書き出しました +@msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください +@msg could not decode image: {0} 画像をデコードできませんでした: {0} +@msg image is empty 画像が空です +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 画像が {width} × {height} ピクセルで、画像トレースで扱える上限 ({max} メガピクセル) を超えています +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead フラットカラーのロゴではないため (ベタ塗り部分のうち少数の単色に一致するのは {_1}% のみ)、フラットロゴモードではグラデーションがまだらになります: 代わりにカラーモードを使用してください +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset このトレースではアンカーポイントが {max} 個を超え、ドキュメントを滑らかに表示できなくなります: 「ノイズ」を上げる、「パス」か「カラー」を下げる、または精度の低いプリセットを選択してください +@msg internal error: {msg} (please report this bug) 内部エラー: {msg} (このバグを報告してください) +@msg the background worker stopped バックグラウンド処理が停止しました +@msg {_1} failed: {e} {_1} に失敗しました: {e} +@msg {label}… {label}… +@msg no blend options ブレンドオプションがありません +@msg bad color {v} カラー {v} が正しくありません +@msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです +@msg give corners of the path: anchors between two straight sides パスの角 (2 本の直線の辺に挟まれたアンカーポイント) を指定してください +@msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください +@msg the drawing is no longer loaded 図面はもう読み込まれていません +@msg No saved selections (Select → Save Selection…) 保存した選択範囲はありません (選択 → 選択範囲を保存…) +@msg effect {k} is `{_1}`, not `{id}` インデックス {k} の効果は `{id}` ではなく `{_1}` です +@msg no effect at index {k} インデックス {k} に効果がありません +@msg unknown effect `{id}` `{id}` は不明な効果です +@msg `{id}` has no envelope dialog `{id}` にはエンベロープのダイアログがありません +@msg select an envelope エンベロープを選択してください +@msg no format to export to 書き出し形式が指定されていません +@msg nothing to export: the document has no visible art (or turn on Use Artboards) 書き出すものがありません: ドキュメントに表示されているアートがありません (または「アートボードを使用」をオンにしてください) +@msg add a format to export 書き出す形式を追加してください +@msg check an asset to export 書き出すアセットにチェックを入れてください +@msg `{tool}` isn't a freehand tool `{tool}` はフリーハンドツールではありません +@msg bad colour `{hex}` (#rrggbb) カラー `{hex}` が正しくありません (#rrggbb の形式で指定してください) +@msg no gradient stop or freeform point is selected グラデーションの分岐点またはフリーフォームポイントが選択されていません +@msg no graphic style `{n}` グラフィックスタイル `{n}` が見つかりません +@msg select two or more graphic styles to merge 統合するグラフィックスタイルを 2 つ以上選択してください +@msg the PDF is no longer loaded PDF はもう読み込まれていません +@msg no such layer or object 指定されたレイヤーまたはオブジェクトが見つかりません +@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません +@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます +@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください +@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note} +@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note} +@msg choose a location for the package パッケージの保存場所を選択してください +@msg missing `folder` `folder` が指定されていません +@msg the package came back damaged 返されたパッケージのデータが破損しています +@msg no preset editor プリセットの編集ダイアログが開いていません +@msg no perspective grid preset named `{name}` `{name}` という名前の遠近グリッドプリセットが見つかりません +@msg no active perspective plane アクティブな遠近面がありません +@msg plane must be left, right or ground plane には left、right、ground のいずれかを指定してください +@msg missing `id` (see plugin.list) `id` が指定されていません (plugin.list を参照) +@msg no plug-in {id} is installed プラグイン {id} はインストールされていません +@msg {_1} is a live effect (Effect › Plug-ins) {_1} はライブ効果です (「効果 › プラグイン」から使用してください) +@msg a preset named `{_1}` exists `{_1}` という名前のプリセットはすでに存在します +@msg no print preset named `{base}` `{base}` という名前のプリントプリセットが見つかりません +@msg no print preset named `{name}` `{name}` という名前のプリントプリセットが見つかりません +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) `{name}` という名前の保存済みプリントプリセットが見つかりません ({DEFAULT_PRESET} は保護されています: これを元に新しいプリセットを作成してください) +@msg print settings are not an object プリント設定がオブジェクト形式ではありません +@msg no colour group `{g}` カラーグループ `{g}` が見つかりません +@msg select artwork (or a colour group) to recolor 再配色するアートワーク (またはカラーグループ) を選択してください +@msg Downloaded {_1} file(s) {_1} 個のファイルをダウンロードしました +@msg Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします) +@msg no file system here この環境にはファイルシステムがありません +@msg nothing to preview プレビューするものがありません +@msg Saved {n} files beside {path} {path} と同じ場所にファイルを {n} 個保存しました +@msg `{_1}` is no save command `{_1}` は保存コマンドではありません +@msg name the file ファイル名を入力してください +@msg unknown format 不明な形式です +@msg PDF settings are not an object PDF 設定がオブジェクト形式ではありません +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}` という名前の保存済み PDF プリセットが見つかりません (組み込みのプリセットは読み取り専用です: それを元に新しいプリセットを作成してください) +@msg Saved to User Defined: {_1} 「ユーザー定義」に保存しました: {_1} +@msg choose a direction to divide in 分割する方向を選択してください +@msg choice must be "merge" or "add", not `{choice}` choice には "merge" または "add" を指定してください (`{choice}` は指定できません) +@msg `{name}` has no options `{name}` にはオプションがありません +@msg no swatch `{name}` スウォッチ `{name}` が見つかりません +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` はカラーではありません (#rrggbb またはプリセット名で指定してください) +@msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください +@msg no such width point 指定された幅ポイントが見つかりません +@msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください +@msg unknown panel `{raw}` `{raw}` は不明なパネルです +@msg x and y must both be numbers x と y はどちらも数値で指定してください +@msg Downloaded {what} {what} をダウンロードしました +@msg Exported {count} file(s) to {folder} {count} 個のファイルを {folder} に書き出しました +@msg Imported {what}: {_1} {what} を読み込みました: {_1} +@msg Installed plug-in {_1} プラグイン {_1} をインストールしました +@msg Opened with {_1} note(s): {_2} ドキュメントを開きました (注意 {_1} 件): {_2} +@msg Saved {path} {path} を保存しました +@msg no app to open files here この環境にはファイルを開くアプリがありません +@msg no data データがありません +@msg no document ドキュメントがありません +@msg no file manager here この環境にはファイルマネージャーがありません +@msg no revert command 「保存済みの状態に戻す」コマンドがありません +@msg the Save PDF dialog didn't open PDF 保存ダイアログが開きませんでした +@msg the document has never been saved ドキュメントはまだ一度も保存されていません +@msg the export returned unreadable data 書き出し処理から読み取れないデータが返されました +@msg {status} ({e}) {status} ({e}) +@msg Exported {what} with {_1} note(s): {first} {what} を書き出しましたが、注意事項が {_1} 件あります: {first} +@msg Saved {path} with {_1} note(s): {first} {path} を保存しましたが、注意事項が {_1} 件あります: {first} +@msg Couldn't open {name}: {e} {name} を開けませんでした: {e} +@msg Couldn't read {name}: {e} {name} を読み込めませんでした: {e} +@msg Internal error: {msg} (please report this bug) 内部エラー: {msg} (このバグを報告してください) +@msg Opened {url} {url} を開きました +@msg No saved views (View → New View…) 保存した表示はありません (表示 → 新規表示…) +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` には、メニュー項目がダイアログを開くコマンドを指定してください (ui.menuDialog を参照) +@msg `{key}` must be true or false `{key}` には true または false を指定してください +@msg brightness must be dark|mediumDark|mediumLight|light brightness には dark|mediumDark|mediumLight|light のいずれかを指定してください +@msg index must be a number or first, previous, next or last index には数値、または first、previous、next、last のいずれかを指定してください +@msg index must be a number or first, previous, next or last, not `{other}` index には数値、または first、previous、next、last のいずれかを指定してください (`{other}` は指定できません) +@msg lang must be one of {_1} lang には {_1} のいずれかを指定してください +@msg missing `name` `name` が指定されていません +@msg missing `tool` `tool` が指定されていません +@msg missing index index が指定されていません +@msg multiple windows land with M11.5 複数ウィンドウは M11.5 で対応予定です +@msg no PNG encoder PNG エンコーダーがありません +@msg no document open ドキュメントが開かれていません +@msg no effect applied yet まだ効果を適用していません +@msg no such perspective grid preset 指定された遠近グリッドプリセットが見つかりません +@msg no such recent file 指定された最近使ったファイルが見つかりません +@msg no such recent font 指定された最近使ったフォントが見つかりません +@msg no such saved selection 指定された選択範囲は保存されていません +@msg no such user library 指定されたユーザーライブラリが見つかりません +@msg no such view 指定された表示が見つかりません +@msg printing isn't available here この環境ではプリントできません +@msg select an image 画像を選択してください +@msg the document has no artboards ドキュメントにアートボードがありません +@msg tool (a tool id) is required tool (ツール ID) を指定してください +@msg unknown tool `{t}` `{t}` は不明なツールです +@msg no such action 指定されたアクションが見つかりません +@msg choose a folder to export to 書き出し先のフォルダーを選択してください +@msg the selection has no URL (or they differ) 選択したオブジェクトに URL がありません (または URL がそれぞれ異なります) +@msg select some type first 先にテキストを選択してください +@msg {_1} font families available {_1} 個のフォントファミリーを使用できます +@msg {_1} has no {_2} style {_1} には {_2} スタイルがありません +@msg Copied the CSS of {rules} object(s) {rules} 個のオブジェクトの CSS をコピーしました +@msg Exported {path} {path} を書き出しました +@msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください +@msg unknown highlight `{h}` `{h}` は不明なハイライトの指定です +@msg unknown overprints `{o}` (preserve, simulate or discard) `{o}` は不明なオーバープリントの指定です (preserve、simulate、discard のいずれか) +@msg unknown preset `{name}` (see flattener.presets.list) `{name}` は不明なプリセットです (flattener.presets.list を参照してください) +@msg Select text or click into text with the Type tool to insert glyphs 字形を挿入するには、テキストを選択するか、文字ツールでテキスト内をクリックしてください +@msg `library` is a library id or name (see {list}) `library` にはライブラリの ID または名前を指定してください ({list} を参照) +@msg no library `{key}` (see {list}) ライブラリ `{key}` が見つかりません ({list} を参照) +@msg select a linked image: an embedded one has no file リンク画像を選択してください: 埋め込み画像にはファイルがありません +@msg {path} can't be found: relink it first {path} が見つかりません: 先に再リンクしてください +@msg Embedded {n} image(s); {missing} missing: relink them first 画像を {n} 枚埋め込みましたが、{missing} 枚は見つかりません: 先に再リンクしてください +@msg Relinked {_1} image(s) 画像を {_1} 枚再リンクしました +@msg Relinked {n} image(s) 画像を {n} 枚再リンクしました +@msg Relinked {n} image(s); not in that folder: {_1} 画像を {n} 枚再リンクしましたが、次の画像はそのフォルダーにありません: {_1} +@msg Unembedded to {_1} 埋め込みを解除して {_1} に保存しました +@msg Updated {_1} image(s) 画像を {_1} 枚更新しました +@msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照) +@msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています +@msg no files to place 配置するファイルがありません +@msg paths: give at least one file paths: ファイルを 1 つ以上指定してください +@msg Couldn't place {name}: {e} {name} を配置できませんでした: {e} +@msg no print service プリントサービスがありません +@msg the print job has no data プリントジョブにデータがありません +@msg there are no printer settings to open here この環境では開けるプリンター設定がありません +@msg Couldn't save recovery data: {e} 復帰用データを保存できませんでした: {e} +@msg Saved recovery data for {title} {title} の復帰用データを保存しました +@msg Discarded {_1} recovered document(s) 復帰用データのドキュメントを {_1} 件破棄しました +@msg Restored {_1} recovered document(s): save them to keep them 復帰用データからドキュメントを {_1} 件復元しました: 残すには保存してください +@msg Saving recovery data for {_1} {_1} の復帰用データを保存中 +@msg bad shortcut ショートカットが正しくありません +@msg can't parse shortcut `{c}` ショートカット `{c}` を解釈できません +@msg can't parse shortcut `{s}` for `{k}` `{k}` のショートカット `{s}` を解釈できません +@msg no dialog open ダイアログが開かれていません +@msg no file reader ファイルの読み込み機能がありません +@msg no file writer ファイルの書き込み機能がありません +@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft のショートカットセットではありません (`overrides` がありません) +@msg shortcut for `{k}` must be a string `{k}` のショートカットは文字列で指定してください +@msg tool shortcuts must be a single key (optionally with Shift) ツールのショートカットには 1 つのキーを指定してください (Shift との組み合わせも可) +@msg unknown preset (one of {_1}) 不明なプリセットです ({_1} のいずれかを指定してください) +@msg unknown tool or command `{key}` `{key}` は不明なツールまたはコマンドです +@msg {_1} is already used by {_2} {_1} はすでに {_2} で使用されています +@msg Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした: {e} +@msg Couldn't paste from the clipboard: {e} クリップボードから貼り付けられませんでした: {e} +@msg nothing selected 何も選択されていません +@msg the {tool} tool has no options {tool} ツールにはオプションがありません +@msg unknown tool `{tool}` `{tool}` は不明なツールです +@msg {tool} isn't in a toolbar group of several tools {tool} は複数のツールからなるツールグループに含まれていません +@msg no such document 指定されたドキュメントが見つかりません +@msg {_1}: not saved, so not closed {_1}: 保存されていないため、閉じませんでした +@msg `{name}` is a built-in workspace; choose another name `{name}` は組み込みのワークスペースなので、別の名前を指定してください +@msg `{to}` is not an available name `{to}` という名前は使用できません +@msg no user workspace `{from}` ユーザーワークスペース `{from}` が見つかりません +@msg unknown workspace (one of {_1}) 不明なワークスペースです ({_1} のいずれかを指定してください) +@msg workspace name can't be empty ワークスペース名を入力してください diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index b7f65e1b..f43c6f97 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -84,8 +84,8 @@ fn plural_east_slavic(n: u64) -> usize { /// The registry. English first: it is the fallback and the source language. pub static LANGUAGES: [LangInfo; 11] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, - // Japanese: the whole interface (every menu string and `tl!` literal), keeping the product, - // workspace and perspective preset names in English (`MENU_KEEP_AS_IS`). + // Japanese: the whole interface (every menu string and `tl!` literal) and the status and error + // messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`). LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet. LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() }, diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e7bf639b..9283d1ed 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -872,7 +872,7 @@ fn complete_languages_translate_every_message() { } /// Languages whose catalogs cover every status and error message. -const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru", "uk"]; +const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ja", "ru", "uk"]; /// Crates whose error and status messages reach the status bar. const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"]; diff --git a/docs/development.md b/docs/development.md index 6e3806f5..734aadeb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -167,10 +167,10 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian, Russian and Ukrainian cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian, Japanese, Russian and Ukrainian cover every message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in `COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`, - a `fr.tsv`, an `it.tsv`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui + a `fr.tsv`, an `it.tsv`, a `ja.tsv`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui complete_languages_translate_every_message` lists them all). Other languages show messages in English until they add `@msg` rows. - Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab From 562e09707a5e4700a46e7501c9b01138015fc642 Mon Sep 17 00:00:00 2001 From: Juan Manuel Verges Solanas Date: Fri, 9 Oct 2026 19:03:09 +0000 Subject: [PATCH 17/43] Fix #813: type can name an installed version of its font A font library can hold two versions of one family and style (a new Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept only the first one loaded, so type set in the other couldn't be matched when a PDF or .ai was opened: it came in as outlines. Now every version loads (the first stays the one family and style resolve to), PDF import tries the other versions when the glyphs differ, and type set in a version the family and style alone wouldn't pick names it in CharStyle::font_version (left out when None, so documents without it are unchanged). Layout, shaping, the substituted-glyph highlight, Find Font, Package and Document Info follow it; changing the font clears it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/text.rs | 6 +++ crates/engine/src/cmd/docinfo.rs | 20 +++++---- crates/engine/src/cmd/fonts.rs | 44 ++++++++++++------ crates/engine/src/cmd/package.rs | 12 +++-- crates/engine/src/cmd/textedit.rs | 8 +++- crates/engine/src/cmd/typecmd.rs | 4 ++ crates/pdf/src/import.rs | 23 +++++++++- crates/pdf/src/import_text.rs | 3 ++ crates/pdf/src/tests_live_text.rs | 2 + crates/pdf/src/tests_realtext.rs | 23 ++++++++++ crates/render/src/lib.rs | 6 ++- crates/text/src/fontdb.rs | 74 +++++++++++++++++++++++++++++-- crates/text/src/layout.rs | 6 ++- crates/text/src/lib.rs | 2 + crates/text/src/shape.rs | 12 +++-- crates/text/src/test_fonts.rs | 27 +++++++++++ crates/text/src/tests_versions.rs | 43 ++++++++++++++++++ 18 files changed, 279 insertions(+), 38 deletions(-) create mode 100644 crates/text/src/tests_versions.rs diff --git a/ROADMAP.md b/ROADMAP.md index f18d3a53..6554d0ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ec9ca824..aef35e23 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -27,6 +27,11 @@ pub struct CharStyle { pub font_family: String, #[serde(default = "regular")] pub font_style: String, + /// Which installed version of the font (its version string) when several versions of the + /// family and style are installed and the text was set in one the family and style alone + /// wouldn't pick (an imported file's type matched to it); None = the one they pick. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub font_version: Option, /// Size in points. pub size: f64, /// Leading in points; None = Auto (120% of size). @@ -175,6 +180,7 @@ impl Default for CharStyle { Self { font_family: "Source Sans 3".into(), font_style: "Regular".into(), + font_version: None, size: 12.0, leading: None, tracking: 0.0, diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 202be0d5..5af0106f 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -97,7 +97,7 @@ type ImageRow = (String, u32, u32, Option); /// The Document Info categories of `d` (only `category` when given) from `info` (what /// `document.info` reports), its fonts `(family, style)`, image objects and used patterns. -fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec
{ +fn sections(d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], category: Option<&str>) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect() }; @@ -135,14 +135,18 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag "patterns" => names("patternNames"), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), "symbols" => names("symbols"), - "fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(), + "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), "fontDetails" => { let db = vectorcraft_text::FontDb::global(); fonts .iter() - .map(|(family, style)| { - // Found by any of its names; a style the family lacks shows in another. - let detail = match db.resolve(family, style) { + .map(|used| { + let (family, style, version) = used; + // Found by any of its names (in the version the type names); a style the + // family lacks shows in another. + let resolved = + db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); + let detail = match resolved { Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { let file = f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); @@ -156,7 +160,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), None => "missing".into(), }; - (format!("{family} {style}"), detail) + (super::fonts::font_label(used), detail) }) .collect() } @@ -243,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result { NodeKind::Compound { .. } => Some("compoundPaths"), NodeKind::Text(t) => { for run in &t.runs { - fonts.insert((run.style.font_family.clone(), run.style.font_style.clone())); + fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone())); } Some("textObjects") } @@ -300,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result { } }); } - let font_names: Vec = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect(); + let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); let mut out = json!({ "document": document_summary(d), "objects": counts, diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 45142990..71d642c3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Fonts in Document", [], None, - "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", + "{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)", has_doc, fonts ), @@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result> { Ok(v) } -/// The fonts (family, style) of the type in the layers and symbols of `d`. -pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> { +/// A font as type names it: family, style and, when it names one, the installed version. +pub(super) type UsedFont = (String, String, Option); + +/// How a font is named in reports: family and style, then the version the type names, if any. +pub(super) fn font_label((family, style, version): &UsedFont) -> String { + match version { + Some(v) => format!("{family} {style} ({v})"), + None => format!("{family} {style}"), + } +} + +/// The fonts (family, style, version) of the type in the layers and symbols of `d`. +pub(super) fn used_fonts(d: &Document) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); + fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()))); } }; d.walk(&mut add); @@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option { let db = vectorcraft_text::FontDb::global(); let mut missing: Vec = used_fonts(d) .into_iter() - .filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) - .map(|(family, _)| family) + .filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) + .map(|(family, _, _)| family) .collect(); - // Sorted by family: its styles are neighbours. + // Sorted by family: its styles and versions are neighbours. missing.dedup(); (!missing.is_empty()).then(|| { format!( @@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result { let ids = scope(s, bool_or(p, "selectionOnly", false))?; let d = &s.doc()?.doc; let db = vectorcraft_text::FontDb::global(); - let mut found: BTreeMap<(String, String), (usize, Vec, String)> = BTreeMap::new(); + let mut found: BTreeMap, String)> = BTreeMap::new(); for id in ids { for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() { - let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default(); + let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default(); e.0 += 1; e.2.push_str(&r.text); if !e.1.contains(&id) { @@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result { } let list: Vec = found .into_iter() - .map(|((family, style), (runs, objects, chars))| { - let resolved = db.resolve(&family, &style); + .map(|((family, style, version), (runs, objects, chars))| { + // The face the type is set in: the version it names when that one is installed. + let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing); let mut lacking: Vec = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect(); lacking.sort_unstable(); lacking.dedup(); lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c))); - json!({ + let mut row = json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": status == vectorcraft_text::FontMatch::Missing, "status": status.as_str(), "resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })), "missingGlyphs": lacking.len(), - }) + }); + if let Some(v) = version { + row["version"] = json!(v); + } + row }) .collect(); Ok(json!(list)) @@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result { .unwrap_or_else(|| r.style.font_style.clone()); r.style.font_family = tf.clone(); r.style.font_style = style; + // Another font: none of the old one's versions. + r.style.font_version = None; changed = true; n += 1; } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 046db3a0..c3399072 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -107,10 +107,14 @@ fn package(s: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); let mut files = HashSet::new(); lines.push("FONTS".to_string()); - for (family, style) in super::fonts::used_fonts(&st.doc) { - let font = format!("{family} {style}"); - // Found by any of its names, as the canvas draws it. - let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing); + for used in super::fonts::used_fonts(&st.doc) { + let font = super::fonts::font_label(&used); + let (family, style, version) = used; + // Found by any of its names, as the canvas draws it (in the version the type names). + let resolved = db + .resolve(&family, &style) + .filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing) + .map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m)); // A style the family lacks is shown in its closest style, whose file is copied. let shown = match &resolved { Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 1b207e86..729194c9 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -316,6 +316,10 @@ impl CharChange { if let Some(v) = &self.style { st.font_style = v.clone(); } + // Another font: none of the old one's versions. + if self.font.is_some() || self.style.is_some() { + st.font_version = None; + } if let Some(v) = self.size { st.size = v.clamp(0.1, 1296.0); } @@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec let db = vectorcraft_text::FontDb::global(); let mut faces = std::collections::HashMap::new(); let mut covers = |st: &CharStyle, c: char| { - let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style)); + let face = faces + .entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone())) + .or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref())); face.as_ref().map(|f| f.covers(c)) }; // Byte ranges that keep a font (family, style). diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7604e908..a9921c6e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -416,6 +416,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result { if let Some(f) = &style { st.font_style = f.clone(); } + // Another font: none of the old one's versions. + if font.is_some() || style.is_some() { + st.font_version = None; + } if let Some(v) = size { st.size = v.clamp(0.1, 1296.0); } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 92202a24..b59f28c3 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -460,8 +460,12 @@ fn matching_face( return Some(face.clone()); } let db = vectorcraft_text::FontDb::global(); - let others = + // Other installed versions of its family and style (a font library keeps old ones), then the + // family's other styles. + let versions = db.versions(face).into_iter().skip(1).take(16); + let styles = db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s)); + let others = versions.chain(styles); let mut best = own.map(|d| (d, face.clone())); for f in others { if let Some(d) = glyph_deviation(&f, text, o, vertical) @@ -636,6 +640,9 @@ struct Builder<'p> { /// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph /// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines. matched: HashMap>>, + /// Per font: the installed version its type names, when it isn't the one its family and style + /// resolve to (see `CharStyle::font_version`). + versions: HashMap>, /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. mask: Option>, masked: Vec>, @@ -784,6 +791,7 @@ impl<'p> Builder<'p> { cid_text: HashMap::new(), font_chars: HashMap::new(), matched: HashMap::new(), + versions: HashMap::new(), mask: None, masked: vec![], masks: HashMap::new(), @@ -1356,12 +1364,25 @@ impl<'p> Builder<'p> { }; info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) }; } + // A version of the family and style that the two alone don't resolve to: the type names it. + // Kept per font once its face is decided (until then each glyph asks). + let named = |f: &Option>| { + let f = f.as_ref()?; + let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?; + (default.version != f.version).then(|| f.version.clone()) + }; + let version = match self.versions.get(&key) { + Some(v) => v.clone(), + None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(), + None => named(&info.face), + }; let (paint, opacity) = self.paint(paint, stroke.is_some()); let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale)); let look = Look { font: key, family: info.family, style: info.style, + version, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 1b654a08..a8d29a98 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -75,6 +75,8 @@ pub(crate) struct Look { pub font: u128, pub family: String, pub style: String, + /// The installed version of the font when it isn't the one family and style resolve to. + pub version: Option, pub size: f64, pub h_scale: f64, pub fill: Option, @@ -97,6 +99,7 @@ impl Look { CharStyle { font_family: self.family.clone(), font_style: self.style.clone(), + font_version: self.version.clone(), size: round(self.size), h_scale: round(self.h_scale), fill: self.fill.clone().unwrap_or(Paint::None), diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index 0cbf9ecc..bface79d 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp font: 1, family: "Source Sans 3".into(), style: "Regular".into(), + version: None, size: 20.0, h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), @@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() { font: 1, family: "Source Sans 3".into(), style: "Regular".into(), + version: None, size: 20.0, h_scale: 100.0, fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index ef3f770d..5d088c53 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() { assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}"); } } + +/// Type set in an installed font's older version (a library keeps both, one name) reopens as type +/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type +/// names the version, which the family and style alone don't pick. +#[test] +fn type_in_another_installed_version_reopens_in_that_version() { + use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font}; + // The newer version first: the one the family and style resolve to. + FontDb::global().add_font(twin_font(true).unwrap()); + FontDb::global().add_font(twin_font(false).unwrap()); + for version in [None, Some(TWIN_VERSIONS[0])] { + let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) }; + let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let mut found = vec![]; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found.push((t.plain_text(), t.first_style().font_version.clone())); + } + }); + assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}"); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a50cadc5..3ca4de81 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { .runs .iter() .map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) { - Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())), + // The version the type names, when it's installed (see `FontDb::face_version`). + Some((f, m)) => { + let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f); + (m == vectorcraft_text::FontMatch::Missing, Some(face.id())) + } None => (true, None), }) .collect(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8ac24ef1..74aec355 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -52,6 +52,9 @@ pub struct FontFace { pub family: String, /// Typographic style name (e.g. "Semibold", "Italic"). pub style: String, + /// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two + /// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]). + pub version: String, /// usWeightClass-style weight (400 = regular). pub weight: f32, pub italic: bool, @@ -324,6 +327,10 @@ pub struct FontDb { /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock<()>, + /// Families (lowercased) whose installed files were all loaded to find other versions of their + /// faces (see [`FontDb::versions`]); a rescan forgets them. + #[cfg(not(target_arch = "wasm32"))] + version_scanned: RwLock>, /// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned. family_cache: Mutex>>, menu_cache: Mutex>, @@ -1032,6 +1039,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option Vec { let (family, _) = self.canonical(family, ""); let family = family.as_str(); - let mut v: Vec<(bool, f32, String)> = - self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); + let mut v: Vec<(bool, f32, String)> = Vec::new(); + // Each style once, as its first loaded version describes it (versions can disagree on weight). + for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) { + if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) { + v.push((f.italic, f.weight, f.style.clone())); + } + } if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { for cf in &c.faces { if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) { @@ -1267,7 +1283,14 @@ impl FontDb { let data = Arc::new(bytes); let mut added = 0; for (i, spec) in enumerate_faces(&data) { - if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) { + // Another version of a family and style is kept: a document may name it (the first one + // loaded stays the one the family and style alone resolve to). + let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default(); + if self + .read_faces() + .iter() + .any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version) + { continue; } if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) { @@ -1329,6 +1352,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { + // Files may have been installed since: other versions are looked for again. + self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear(); let mut catalog = Catalog::new(); let mut aliases: HashMap> = HashMap::new(); let mut postscript = PostScriptNames::new(); @@ -1520,6 +1545,49 @@ impl FontDb { .or_else(|| self.read_faces().first().cloned()) } + /// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when + /// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives. + pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option> { + let face = self.face(family, style)?; + let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) }; + let same = + |f: &&Arc| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version; + if let Some(f) = self.read_faces().iter().find(same) { + return Some(f.clone()); + } + // Not loaded: the family's installed files may hold it (the catalog doesn't list versions). + self.load_family_files(&face.family); + let found = self.read_faces().iter().find(same).cloned(); + Some(found.unwrap_or(face)) + } + + /// Every installed version of `face`'s family and style, `face` itself first. + pub fn versions(&self, face: &Arc) -> Vec> { + self.load_family_files(&face.family); + let mut out = vec![face.clone()]; + out.extend( + self.read_faces() + .iter() + .filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id) + .cloned(), + ); + out + } + + /// Load the installed files of `family` once (other versions of faces already loaded from + /// elsewhere); a no-op on wasm, where no fonts are installed. + fn load_family_files(&self, family: &str) { + #[cfg(not(target_arch = "wasm32"))] + { + let key = family.to_ascii_lowercase(); + if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) { + self.load_cataloged(family); + } + } + #[cfg(target_arch = "wasm32")] + let _ = family; + } + /// Is `style` of `family` among the installed fonts? #[cfg(not(target_arch = "wasm32"))] fn is_cataloged(&self, family: &str, style: &str) -> bool { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5e1b4dc9..73de86f1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -263,7 +263,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout TextKind::OnPath { .. } => Affine::IDENTITY, TextKind::Point => { let first = cx.style_at(0); - let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = + db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre()); Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN } _ => QUARTER_TURN, @@ -1392,7 +1393,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start)); let st = cx.style_at(pr.start); let (cap, xh) = cap_x_heights(cx.db, st); - let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = + cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre()); Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } }; if pi > 0 { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2c05b799..a499dc67 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -522,6 +522,8 @@ mod tests_typo; #[cfg(test)] mod tests_variable; #[cfg(test)] +mod tests_versions; +#[cfg(test)] mod tests_vertical; #[cfg(test)] #[path = "../build/web_fonts.rs"] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 503614a0..f82f447c 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool { /// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) }; + let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { + return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()); + }; let k = st.size / face.upem; (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } @@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { /// Cap height and x height (points) of a style's resolved face. pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) }; + let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { + return (st.size * 0.7 * vs, st.size * 0.5 * vs); + }; let k = st.size / face.upem * vs; (face.cap_height * k, face.x_height * k) } @@ -291,12 +295,12 @@ pub(crate) fn shape_range( let a = bytes.start; let b = bytes.end; if let Some(art) = art { - if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) { + if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) { out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a))); } continue; } - let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue }; + let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). let small_caps = st.small_caps.is_some() && !st.all_caps; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index fd687522..688f05ef 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option> { } Some(builder.build()) } + +/// The family of [`twin_font`]: two versions of one family and style. +pub const TWIN_FAMILY: &str = "Twintest Sans"; + +/// The version strings of [`twin_font`]'s older and newer face. +pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"]; + +/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled +/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of +/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't +/// be built. +pub fn twin_font(newer: bool) -> Option> { + const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?; + let version = TWIN_VERSIONS[usize::from(newer)]; + let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} diff --git a/crates/text/src/tests_versions.rs b/crates/text/src/tests_versions.rs new file mode 100644 index 00000000..91250fdd --- /dev/null +++ b/crates/text/src/tests_versions.rs @@ -0,0 +1,43 @@ +//! Two installed versions of one family and style (a font library keeps an old version beside a +//! new one): both load, the family and style alone keep resolving to the first, and type that names +//! the other version is set in it. + +use kurbo::Point; +use vectorcraft_doc::CharStyle; + +use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font}; +use super::*; + +/// A database of the bundled fonts, the newer version of the test family loaded first. +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1); + assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept"); + assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice"); + db +} + +fn width(db: &FontDb, version: Option<&str>) -> f64 { + let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() }; + layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum() +} + +#[test] +fn a_version_is_found_by_its_version_string() { + let db = db(); + let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face"); + assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default"); + assert_eq!(face(None).version, VERSIONS[1]); + assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]); + assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default"); + let all = db.versions(&face(None)); + assert_eq!(all.iter().map(|f| f.version.as_str()).collect::>(), [VERSIONS[1], VERSIONS[0]]); +} + +#[test] +fn type_naming_a_version_is_set_in_it() { + let db = db(); + let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0]))); + assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}"); + assert_eq!(width(&db, Some(VERSIONS[1])), newer); +} From c9ba5db4fbc415b382d01bb4900594d4f3218769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Fri, 9 Oct 2026 20:00:03 +0100 Subject: [PATCH 18/43] =?UTF-8?q?Part=20of=20#394:=20Preferences=20?= =?UTF-8?q?=E2=80=BA=20Hyphenation=20=E2=80=BA=20Exceptions=20reach=20the?= =?UTF-8?q?=20hyphenator.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comma-separated words never break, or break only at marked hyphens; prefs.set and restore push the list so type reflows. --- ROADMAP.md | 4 +- crates/engine/src/cmd/prefscmds.rs | 9 ++ crates/engine/src/tests_prefs.rs | 24 ++++ crates/text/src/hyphen.rs | 169 +++++++++++++++++++++++++++++ crates/text/src/lib.rs | 1 + 5 files changed, 205 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d2a384f2..b5477aab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,7 +114,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). About 20 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace). + - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). About 19 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. @@ -232,7 +232,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 19 Preferences options stored but not yet acted on (#394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 | | Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index aba7006f..e132730b 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -475,6 +475,7 @@ impl Session { let history_changed = p.history_states != self.prefs.history_states; let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color; let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder; + let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions; self.prefs = p; if fonts_folder_changed { let folder = self.prefs.fonts_folder.trim(); @@ -486,6 +487,10 @@ impl Session { let _ = super::fonts::rescan(self, &serde_json::Value::Null); } } + if hyphen_exceptions_changed { + // Preferences › Hyphenation › Exceptions (#394): the layout reads the process-wide list. + vectorcraft_text::set_hyphenation_exceptions(&self.prefs.hyphenation_exceptions); + } vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok()); for st in &mut self.docs { if history_changed { @@ -503,6 +508,10 @@ impl Session { d.grid.subdivisions = self.prefs.grid_subdivisions; st.revision += 1; } + // Hyphenation exceptions change every text object's line breaks. + if hyphen_exceptions_changed { + st.revision += 1; + } } // Plug-ins in a newly set Additional Plug-ins Folder are installed. super::plugin::sync_prefs(self); diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 680c5732..68f73f2f 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -670,3 +670,27 @@ world", "size": 20}), let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") }; assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into()))); } + +/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator +/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`. +#[test] +fn hyphenation_exceptions_preference_reaches_the_hyphenator() { + struct Clear; + impl Drop for Clear { + fn drop(&mut self) { + vectorcraft_text::set_hyphenation_exceptions(""); + } + } + let _clear = Clear; + let mut s = Session::new(); + set_pref(&mut s, "hyphenationExceptions", json!("")); + assert!(vectorcraft_text::hyphenation_exceptions().is_empty()); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let before = s.doc().unwrap().revision; + set_pref(&mut s, "hyphenationExceptions", json!("typography, hap-pen")); + assert_eq!(s.prefs.hyphenation_exceptions, "typography, hap-pen"); + assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen"); + assert!(s.doc().unwrap().revision > before, "type reflows"); + set_pref(&mut s, "hyphenationExceptions", json!("")); + assert!(vectorcraft_text::hyphenation_exceptions().is_empty()); +} diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 0b44114b..5ba9b392 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -1,12 +1,115 @@ //! Pattern-free English hyphenation: break long words between syllables at vowel–consonant //! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`] //! letters before or [`MIN_AFTER`] after the hyphen. +//! +//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words: +//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See +//! [`set_hyphenation_exceptions`]. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, OnceLock}; /// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters). pub const MIN_WORD: usize = 6; pub const MIN_BEFORE: usize = 3; pub const MIN_AFTER: usize = 3; +/// An exception for one word: never break it, or break only at the listed character indices. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Exception { + Never, + Only(Vec), +} + +#[derive(Clone, Default)] +struct Exceptions { + source: String, + map: HashMap, +} + +fn store() -> MutexGuard<'static, Exceptions> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(Exceptions::default())).lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn parse_list(source: &str) -> Exceptions { + let mut map = HashMap::new(); + for entry in source.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let (word, ex) = parse_exception(entry); + if word.is_empty() { + continue; + } + map.insert(word, ex); + } + Exceptions { source: source.to_string(), map } +} + +/// Preferences › Hyphenation › Exceptions: comma-separated words. A word with no hyphens is never +/// hyphenated; hyphens mark the only allowed break points (`ty-pog-ra-phy`). Matching is +/// case-insensitive. Empty clears the list. +pub fn set_hyphenation_exceptions(source: &str) { + *store() = parse_list(source); +} + +/// The last string passed to [`set_hyphenation_exceptions`] (empty when none). +pub fn hyphenation_exceptions() -> String { + store().source.clone() +} + +fn parse_exception(entry: &str) -> (String, Exception) { + // A leading tilde with no other markers means "never", as in dictionary hyphenation UIs. + if let Some(rest) = entry.strip_prefix('~') + && !rest.contains('-') + && !rest.contains('~') + { + return (normalize_word(rest), Exception::Never); + } + if !entry.contains('-') { + return (normalize_word(entry), Exception::Never); + } + let mut word = String::new(); + let mut points = Vec::new(); + for ch in entry.chars() { + if ch == '-' { + let at = word.chars().count(); + if at > 0 { + points.push(at); + } + } else { + for c in ch.to_lowercase() { + word.push(c); + } + } + } + let n = word.chars().count(); + points.retain(|&p| p > 0 && p < n); + points.sort_unstable(); + points.dedup(); + (word, Exception::Only(points)) +} + +fn normalize_word(word: &str) -> String { + word.chars().flat_map(|c| c.to_lowercase()).collect() +} + +fn lookup(ex: &Exceptions, word: &str) -> Option { + ex.map.get(&normalize_word(word)).cloned() +} + +fn active_exceptions() -> Exceptions { + #[cfg(test)] + { + if let Some(local) = TEST_OVERRIDE.with(|c| c.borrow().clone()) { + return local; + } + } + store().clone() +} + fn is_vowel(c: char) -> bool { matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii()) } @@ -25,7 +128,14 @@ fn onset_pair(a: char, b: char) -> bool { /// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char). /// Words containing non-letters (other than a trailing apostrophe) are not hyphenated. +/// Preferences exceptions override the pattern for a matching word. pub fn hyphen_points(word: &str) -> Vec { + if let Some(ex) = lookup(&active_exceptions(), word) { + return match ex { + Exception::Never => vec![], + Exception::Only(pts) => pts, + }; + } let c: Vec = word.chars().collect(); let n = c.len(); if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) { @@ -69,3 +179,62 @@ pub fn hyphenate_word(word: &str) -> String { } s } + +#[cfg(test)] +thread_local! { + /// Per-thread list so unit tests don't race the process-wide preferences store. + static TEST_OVERRIDE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_exceptions(source: &str, f: impl FnOnce()) { + TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source))); + f(); + TEST_OVERRIDE.with(|c| *c.borrow_mut() = None); + } + + /// #394 Preferences › Hyphenation › Exceptions contract (parse + override). + #[test] + fn hyphenation_exceptions_override_the_pattern() { + let pattern = hyphen_points("typography"); + assert_eq!(pattern, vec![4, 7], "pattern: typo-gra-phy"); + + // Bare word: never break (case-insensitive). + with_exceptions("typography", || { + assert!(hyphen_points("typography").is_empty()); + assert!(hyphen_points("Typography").is_empty(), "case-insensitive"); + }); + + // Hyphens mark the only allowed breaks (stricter than the pattern). + with_exceptions("typo-graphy", || { + assert_eq!(hyphen_points("typography"), vec![4], "only the marked break"); + assert_eq!(hyphenate_word("typography"), "typo-graphy"); + }); + + // Comma-separated list; empty / whitespace entries ignored. + with_exceptions(" , typography , hap-pen , ", || { + assert!(hyphen_points("typography").is_empty()); + assert_eq!(hyphen_points("happen"), vec![3]); + assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern"); + }); + + // Leading tilde: never hyphenate (dictionary-style). + with_exceptions("~extraordinary", || { + assert!(hyphen_points("extraordinary").is_empty()); + }); + + assert_eq!(hyphen_points("typography"), pattern, "override cleared"); + } + + /// Process-wide store used by `prefs.set` / restore. + #[test] + fn set_hyphenation_exceptions_round_trips_the_source_string() { + set_hyphenation_exceptions("typography, hap-pen"); + assert_eq!(hyphenation_exceptions(), "typography, hap-pen"); + set_hyphenation_exceptions(""); + assert!(hyphenation_exceptions().is_empty()); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e89a62ce..02242600 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -30,6 +30,7 @@ pub use fontdb::{ FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs, }; +pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; From 4f9b292213274dcf08e286e4aca64e7f15246eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Fri, 9 Oct 2026 20:23:32 +0100 Subject: [PATCH 19/43] =?UTF-8?q?Part=20of=20#394:=20headless=20tests=20fo?= =?UTF-8?q?r=20Hyphenation=20=E2=80=BA=20Exceptions=20UI=20and=20layout.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- crates/text/src/hyphen.rs | 12 +++++++++--- crates/text/src/tests_typo.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/prefs_dialog.rs | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 5ba9b392..9255d76c 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -186,14 +186,20 @@ thread_local! { static TEST_OVERRIDE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } +/// Run `f` with Preferences › Hyphenation › Exceptions applied on this thread only (tests). +#[cfg(test)] +pub(crate) fn with_hyphenation_exceptions_for_test(source: &str, f: impl FnOnce()) { + TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source))); + f(); + TEST_OVERRIDE.with(|c| *c.borrow_mut() = None); +} + #[cfg(test)] mod tests { use super::*; fn with_exceptions(source: &str, f: impl FnOnce()) { - TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source))); - f(); - TEST_OVERRIDE.with(|c| *c.borrow_mut() = None); + with_hyphenation_exceptions_for_test(source, f); } /// #394 Preferences › Hyphenation › Exceptions contract (parse + override). diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 136c7cc3..4897e53b 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -415,6 +415,26 @@ fn hyphenated_layout_adds_hyphens() { } } +/// Preferences › Hyphenation › Exceptions (#394): listing a word stops the layout from breaking it. +#[test] +fn hyphenation_exceptions_keep_listed_words_whole_in_layout() { + // Same copy as `hyphenated_layout_adds_hyphens` (known to produce soft hyphens at 120pt width). + let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything."; + let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left); + t.para.hyphenate = true; + let with_breaks = layout(db(), &t); + let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count(); + assert!(hyphens(&with_breaks) > 0, "pattern produces soft hyphens"); + hyphen::with_hyphenation_exceptions_for_test( + "extraordinarily, comprehensive, typographical, considerations, notwithstanding, everything", + || { + let blocked = layout(db(), &t); + assert_eq!(hyphens(&blocked), 0, "every long word listed: no soft hyphens"); + assert!(blocked.lines.len() >= with_breaks.lines.len()); + }, + ); +} + #[test] fn soft_hyphen_is_invisible_mid_line() { let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0))); diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 300b570d..743631c4 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -577,4 +577,28 @@ mod tests { assert!(PREF_SPECS.iter().any(|s| s.category == *c)); } } + + /// Preferences › Hyphenation › Exceptions (#394): OK on the dialog pushes the list into the + /// hyphenator the same way `prefs.set` does (no need to drive the live Preferences window). + #[test] + fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() { + struct Clear; + impl Drop for Clear { + fn drop(&mut self) { + vectorcraft_text::set_hyphenation_exceptions(""); + } + } + let _clear = Clear; + let mut a = app(); + open(&mut a, Some("Hyphenation")); + let d = a.ui.dialog.as_mut().unwrap(); + assert_eq!(d.str("__category"), "Hyphenation"); + d.fields.insert("hyphenationExceptions".into(), json!("typography, hap-pen")); + confirm(&mut a).unwrap(); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.prefs.hyphenation_exceptions, "typography, hap-pen"); + assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen"); + assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty()); + assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]); + } } From 445bceb681f5b6c3a81f975780ac5a0f6d93dab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabio=20William=20Concei=C3=A7=C3=A3o?= Date: Fri, 9 Oct 2026 20:26:55 +0100 Subject: [PATCH 20/43] Part of #394: look up hyphenation exceptions under the lock; drop ~ syntax. Co-authored-by: Cursor --- crates/text/src/hyphen.rs | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 9255d76c..93acd4ba 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -21,7 +21,7 @@ enum Exception { Only(Vec), } -#[derive(Clone, Default)] +#[derive(Default)] struct Exceptions { source: String, map: HashMap, @@ -61,13 +61,7 @@ pub fn hyphenation_exceptions() -> String { } fn parse_exception(entry: &str) -> (String, Exception) { - // A leading tilde with no other markers means "never", as in dictionary hyphenation UIs. - if let Some(rest) = entry.strip_prefix('~') - && !rest.contains('-') - && !rest.contains('~') - { - return (normalize_word(rest), Exception::Never); - } + // Illustrator: bare word = never break; hyphens mark the only allowed breaks. No other syntax. if !entry.contains('-') { return (normalize_word(entry), Exception::Never); } @@ -96,18 +90,17 @@ fn normalize_word(word: &str) -> String { word.chars().flat_map(|c| c.to_lowercase()).collect() } -fn lookup(ex: &Exceptions, word: &str) -> Option { - ex.map.get(&normalize_word(word)).cloned() -} - -fn active_exceptions() -> Exceptions { +/// Look the word up under the preferences lock (or the thread-local test override). Only the matched +/// [`Exception`] is cloned, never the whole list. +fn exception_for(word: &str) -> Option { + let key = normalize_word(word); #[cfg(test)] { - if let Some(local) = TEST_OVERRIDE.with(|c| c.borrow().clone()) { - return local; + if let Some(ex) = TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&key).cloned())) { + return Some(ex); } } - store().clone() + store().map.get(&key).cloned() } fn is_vowel(c: char) -> bool { @@ -130,7 +123,7 @@ fn onset_pair(a: char, b: char) -> bool { /// Words containing non-letters (other than a trailing apostrophe) are not hyphenated. /// Preferences exceptions override the pattern for a matching word. pub fn hyphen_points(word: &str) -> Vec { - if let Some(ex) = lookup(&active_exceptions(), word) { + if let Some(ex) = exception_for(word) { return match ex { Exception::Never => vec![], Exception::Only(pts) => pts, @@ -227,11 +220,6 @@ mod tests { assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern"); }); - // Leading tilde: never hyphenate (dictionary-style). - with_exceptions("~extraordinary", || { - assert!(hyphen_points("extraordinary").is_empty()); - }); - assert_eq!(hyphen_points("typography"), pattern, "override cleared"); } From 986b83280fb9e23385fc076398b14b7ed3b57859 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 02:32:27 +0700 Subject: [PATCH 21/43] Fix #808: the toolbar's scroll bar stays a slim line The toolbar and the dock's icon column scroll when the window is too short for them. Their scroll bar was egui's floating one, which widens to 10 pt while scrolled or dragged and covered the group labels and the icons' right edge. It now stays at most 4 pt wide (2 pt at rest), at the column's edge. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/widgets.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 28c6c35b..86cf5982 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -785,8 +785,20 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R /// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the /// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is /// over it. +/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers +/// the buttons (#808). pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner + ui.scope(|ui| { + ui.spacing_mut().scroll = egui::style::ScrollStyle { + bar_width: 4.0, + floating_width: 2.0, + bar_inner_margin: 0.0, + bar_outer_margin: 1.0, + ..egui::style::ScrollStyle::floating() + }; + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner + }) + .inner } /// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a From a262ee5c9e59cfe07510f3b0f2546c0a4901b844 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 02:32:52 +0700 Subject: [PATCH 22/43] Fix #807: All Tools shows every tool with its icon, under headings The toolbar's All Tools drawer listed the tools' names as plain buttons. It now shows each tool with its icon and name in a grid, under the toolbar's group headings (Select, Shapes, Draw, Modify, Type, Navigate, Color) and headings for the tools only the Advanced toolbar shows (Perspective, Symbols, Graph, Artboards, Slices). The active tool is highlighted, a tool's tooltip gives its name and shortcut, and picking one selects it and closes the drawer. The headings use labels translated already. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/all_tools.rs | 116 +++++++++++++++++++++--- crates/ui-egui/src/toolbar.rs | 2 +- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs index a40c777b..402e6f44 100644 --- a/crates/ui-egui/src/dialogs/all_tools.rs +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -1,24 +1,110 @@ -//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer. +//! The All Tools drawer: every tool with its icon and name, under the headings of the toolbar's +//! groups (Select, Shapes, Draw, Modify, Type, Navigate, Color) and of the tools the Basic toolbar +//! leaves out (#807). The active tool is highlighted; picking one selects it and closes the drawer. + +use egui::{Sense, vec2}; +use vectorcraft_tools::{TOOL_GROUPS, ToolInfo}; use super::DialogSpec; -use crate::VectorcraftApp; use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, icons, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM }; + +/// A tool's cell: its icon and name. +const CELL: egui::Vec2 = vec2(176.0, 28.0); +const ICON: f32 = 20.0; + +/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the +/// Advanced toolbar shows. +fn heading(id: &str) -> &'static str { + if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) { + return cat; + } + match id { + _ if id.starts_with("symbol") => "Symbols", + _ if id.ends_with("Graph") => "Graph", + _ if id.starts_with("perspective") => "Perspective", + "artboard" => "Artboards", + "slice" | "sliceSelection" => "Slices", + _ => "More", + } +} + +/// The headings in order, each with its tools in the toolbar's order. +fn sections() -> Vec<(&'static str, Vec<&'static ToolInfo>)> { + let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect(); + for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) { + let h = heading(tool.id); + match out.iter_mut().find(|(c, _)| *c == h) { + Some((_, tools)) => tools.push(tool), + None => out.push((h, vec![tool])), + } + } + out.retain(|(_, tools)| !tools.is_empty()); + out +} fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool { - let mut picked = false; - for g in vectorcraft_tools::TOOL_GROUPS { - ui.horizontal_wrapped(|ui| { - for tool in g.iter() { - let shown = tl!(tool.label); - let shown = if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown }; - if ui.button(shown).clicked() { - app.select_tool(tool.id); - picked = true; + let t = Tokens::get(ui.ctx()); + let active = app.session.tool_id(); + let mut picked = None; + let room = (ui.ctx().content_rect().height() - 180.0).max(200.0); + egui::ScrollArea::vertical().max_height(room).auto_shrink([false, true]).show(ui, |ui| { + for (cat, tools) in sections() { + widgets::subheader(ui, tl!(cat)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + for tool in tools { + let (rect, resp) = ui.allocate_exact_size(CELL, Sense::click()); + let on = tool.id == active; + if on { + ui.painter().rect_filled(rect, 3.0, t.selection); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let icon = egui::Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON)); + icons::paint(ui, icons::tool_icon(tool.icon), icon, if on { t.text_strong } else { t.icon }); + let name = tl!(tool.label); + let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name }; + let text_at = egui::pos2(icon.right() + 8.0, rect.center().y); + ui.painter().with_clip_rect(rect).text( + text_at, + egui::Align2::LEFT_CENTER, + name, + egui::FontId::proportional(12.0), + if on { t.text_strong } else { t.text }, + ); + if resp.on_hover_text(crate::toolbar::tip(tool)).clicked() { + picked = Some(tool.id); + } } - } - }); + }); + ui.add_space(6.0); + } + }); + if let Some(id) = picked { + app.select_tool(id); + } + picked.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order. + #[test] + fn every_tool_is_listed_once_under_a_heading() { + let s = sections(); + let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect(); + let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); + assert_eq!(listed.len(), all.len()); + assert!(all.iter().all(|id| listed.contains(id))); + let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect(); + assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]); + assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}"); + assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); } - picked } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c543618e..e8cfe8c3 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -62,7 +62,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ ("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]), ]; -fn tip(t: &ToolInfo) -> String { +pub(crate) fn tip(t: &ToolInfo) -> String { match crate::shortcut_editor::tool_shortcut(t.id) { Some(s) => format!("{} ({})", tl!(t.label), s), None => tl!(t.label).to_string(), From 78f0297127694b0d5570626700ed122bdd7303f8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 03:00:15 +0700 Subject: [PATCH 23/43] Part of #812: an ellipse's Pie Start and End Angle and Invert Pie An ellipse kept pie angles in its model, but nothing drew or set them. Now the Transform and Properties panels show Ellipse Properties with Pie Start Angle, Pie End Angle and Invert Pie, as in Illustrator, and object.setLiveShape takes {pieStart, pieEnd, invertPie}. The pie runs counterclockwise on screen from the start angle (0 at 3 o'clock) to the end angle and closes through the centre; 0 to 360 is the whole ellipse, an end of 0 means 360, and Invert Pie swaps the angles to show the other part. One undo step each; a bad angle is an error. The labels are translated in every complete language. The on-canvas pie widget is still to come. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/node.rs | 2 +- crates/engine/src/cmd/object.rs | 28 ++++++++++- crates/engine/src/tests_livecorners.rs | 28 +++++++++++ crates/geom/src/shapes.rs | 65 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 4 ++ crates/ui-egui/src/i18n/fr.tsv | 4 ++ crates/ui-egui/src/i18n/it.tsv | 4 ++ crates/ui-egui/src/i18n/ja.tsv | 4 ++ crates/ui-egui/src/i18n/ru.tsv | 4 ++ crates/ui-egui/src/i18n/uk.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++ crates/ui-egui/src/panels/properties.rs | 1 + crates/ui-egui/src/panels/transform.rs | 20 ++++++++ 15 files changed, 175 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6554d0ab..12a2cfc7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786). - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. - - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 665279b6..464f40c7 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -146,7 +146,7 @@ impl LiveShape { LiveShape::Rectangle { w, h, radii, kinds, xf } => { shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf) } - LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf), + LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0, LiveShape::Line { a, b } => shapes::line(*a, *b), LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0, diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ffc9b007..7ef85be9 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -196,7 +196,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)", + "{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)", has_selection, set_live_shape ), @@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { .transpose()?; let radius = p.get("radius").and_then(Value::as_f64); let sides = p.get("sides").and_then(Value::as_u64); + // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). + let angle = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|a| a.is_finite()) + .map(|a| Some(a.rem_euclid(360.0))) + .ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))), + } + }; + let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); + let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); s.edit("Live Shape", |d, sel| { for id in &ids { let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { l.set_sides(n); *path = l.to_path(); } + if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut() + && (pie_start.is_some() || pie_end.is_some() || invert) + { + if let LiveShape::Ellipse { pie, .. } = l { + // An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°). + let end_of = |a: f64| if a == 0.0 { 360.0 } else { a }; + *pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of)); + if invert { + *pie = (pie.1.rem_euclid(360.0), end_of(pie.0)); + } + } + *path = l.to_path(); + } if radius.is_none() && kind.is_none() { continue; } diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index 8988e3c9..023108f8 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -279,3 +279,31 @@ fn a_pen_path_rounds_its_corners_but_not_its_ends() { run(&mut s, json!({"id": e.0, "radius": 8})); assert_eq!(node_path(&s, e), before); } + +/// #812: an ellipse's Pie Start and End Angle cut it as a pie; Invert Pie shows the other part; +/// one undo step each; a bad angle is an error. +#[test] +fn an_ellipse_takes_pie_angles_and_inverts() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap()); + let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()), + k => panic!("{k:?}"), + }; + s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap(); + let (p, b) = pie(&s); + assert_eq!(p, (0.0, 90.0)); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "the quarter up and right of the centre: {b:?}"); + s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap(); + let (p, b) = pie(&s); + assert_eq!(p, (90.0, 360.0)); + assert!(b.x0.abs() < 1e-6 && (b.y1 - 100.0).abs() < 1e-6, "the other three quarters: {b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pie(&s).0, (0.0, 90.0)); + // 0 to 360 (or an end of 0) is the whole ellipse again. + s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 0})).unwrap(); + assert_eq!(pie(&s).0, (0.0, 360.0)); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4); + assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err()); +} diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 756923cd..64db3682 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -86,6 +86,50 @@ pub fn ellipse(r: Rect) -> PathData { PathData::single(SubPath::new(anchors, true)) } +/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured +/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then +/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`]. +/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors. +pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData { + let span = (end - start).rem_euclid(360.0); + if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 { + return ellipse(r); + } + let r = r.abs(); + let c = r.center(); + let (rx, ry) = (r.width() / 2.0, r.height() / 2.0); + // Screen y runs down: counterclockwise on screen is a falling y. + let at = |deg: f64| { + let t = deg.to_radians(); + Point::new(c.x + rx * t.cos(), c.y - ry * t.sin()) + }; + let tangent = |deg: f64| { + let t = deg.to_radians(); + Vec2::new(-rx * t.sin(), -ry * t.cos()) + }; + let pieces = if span <= 90.0 { + 1 + } else if span <= 180.0 { + 2 + } else if span <= 270.0 { + 3 + } else { + 4 + }; + let step = span / f64::from(pieces); + let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan(); + let mut anchors = vec![Anchor::corner(c)]; + for i in 0..=pieces { + let a = start + step * f64::from(i); + let (p, d) = (at(a), tangent(a) * k); + let h_in = if i == 0 { p } else { p - d }; + let h_out = if i == pieces { p } else { p + d }; + let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth }; + anchors.push(Anchor { p, h_in, h_out, kind }); + } + PathData::single(SubPath::new(anchors, true)) +} + /// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre. pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData { let n = sides.max(3); @@ -207,6 +251,27 @@ mod tests { crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat() } + /// A pie is the arc from its start to its end angle, counterclockwise on screen, closed through + /// the centre; a whole turn (or none) is the ellipse. + #[test] + fn a_pie_is_its_arc_closed_through_the_centre() { + let r = Rect::new(0.0, 0.0, 200.0, 100.0); + assert_eq!(ellipse_pie(r, 0.0, 360.0), ellipse(r)); + assert_eq!(ellipse_pie(r, 90.0, 90.0), ellipse(r)); + // A quarter, from 3 o'clock up to 12: the centre, then the arc's two ends. + let q = ellipse_pie(r, 0.0, 90.0); + let a: Vec = q.subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(a.len(), 3); + assert!(a[0].distance(Point::new(100.0, 50.0)) < 1e-9 && a[1].distance(Point::new(200.0, 50.0)) < 1e-9); + assert!(a[2].distance(Point::new(100.0, 0.0)) < 1e-9, "counterclockwise on screen: up"); + assert_eq!(q.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((100.0, 0.0, 200.0, 50.0))); + // Three quarters, from 12 round to 3 o'clock: four pieces at most, the whole box covered. + let big = ellipse_pie(r, 90.0, 0.0); + assert!(big.subpaths[0].anchors.len() <= 6 && big.subpaths[0].closed); + assert_eq!(big.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((0.0, 0.0, 200.0, 100.0))); + assert!(ellipse_pie(r, f64::NAN, 10.0).anchor_count() == 4, "not a number: the whole ellipse"); + } + #[test] fn rect_is_4_corners_clockwise() { let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2af7daac..f1f45dc8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -221,6 +221,10 @@ Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área Auto Spell Check Revisión ortográfica automática Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos + Ellipse Properties: Propiedades de la elipse: + Invert Pie Invertir sector + Pie End Angle: Ángulo final del sector: + Pie Start Angle: Ángulo inicial del sector: Show Tool Group Labels Mostrar etiquetas de grupos de herramientas Auto-Rotate (turn the paper to each artboard) Rotar automáticamente (girar el papel según cada mesa de trabajo) Automatic flatness Lisura automática diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 849006c6..918948f9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -222,6 +222,10 @@ Auto Size New Area Type Taille automatique du nouveau texte captif Auto Spell Check Vérification orthographique automatique Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes + Ellipse Properties: Propriétés de l’ellipse : + Invert Pie Inverser le secteur + Pie End Angle: Angle de fin du secteur : + Pie Start Angle: Angle de début du secteur : Show Tool Group Labels Afficher les libellés des groupes d'outils Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail) Automatic flatness Planéité automatique diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5a4e89bf..5e5fc0a6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -221,6 +221,10 @@ Auto Size New Area Type Dimensionamento automatico del nuovo testo in area Auto Spell Check Controllo ortografico automatico Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona + Ellipse Properties: Proprietà dell’ellisse: + Invert Pie Inverti settore + Pie End Angle: Angolo finale del settore: + Pie Start Angle: Angolo iniziale del settore: Show Tool Group Labels Mostra etichette dei gruppi di strumenti Auto-Rotate (turn the paper to each artboard) Rotazione automatica (orienta la carta in base a ogni tavola da disegno) Automatic flatness Planarità automatica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fd96d775..269a4dc3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -345,9 +345,13 @@ Appearance Attribute アピアランス属性 Apply 適用 Blending Mode 描画モード + Ellipse Properties: 楕円形のプロパティ: Fill & Stroke 塗りと線 Fill Color カラー (塗り) + Invert Pie 扇形を反転 Opacity 不透明度 + Pie End Angle: 扇形の終了角度: + Pie Start Angle: 扇形の開始角度: Stroke Color カラー (線) Stroke Weight 線幅 Graphic Style グラフィックスタイル diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 66a53611..22f908f8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -219,6 +219,10 @@ Auto Size New Area Type Автоматически подгонять размер нового текста в области Auto Spell Check Автоматическая проверка орфографии Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей + Ellipse Properties: Свойства эллипса: + Invert Pie Инвертировать сектор + Pie End Angle: Конечный угол сектора: + Pie Start Angle: Начальный угол сектора: Show Tool Group Labels Показывать названия групп инструментов Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области) Automatic flatness Автоматическая плоскостность diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b3fe25ae..7df9a0e8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -215,6 +215,10 @@ Auto Size New Area Type Автоматично підганяти розмір нового тексту в області Auto Spell Check Автоматична перевірка орфографії Auto-Collapse Iconic Panels Автоматично згортати значки панелей + Ellipse Properties: Властивості еліпса: + Invert Pie Інвертувати сектор + Pie End Angle: Кінцевий кут сектора: + Pie Start Angle: Початковий кут сектора: Show Tool Group Labels Показувати назви груп інструментів Auto-Rotate (turn the paper to each artboard) Автоматичний поворот (повертати аркуш відповідно до кожної монтажної області) Automatic flatness Автоматична площинність diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fb53152a..a0dd0a41 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -204,6 +204,10 @@ Auto Size New Area Type 自动调整新区域文字大小 Auto Spell Check 自动拼写检查 Auto-Collapse Iconic Panels 自动折叠图标面板 + Ellipse Properties: 椭圆属性: + Invert Pie 反转扇形 + Pie End Angle: 扇形终止角度: + Pie Start Angle: 扇形起始角度: Show Tool Group Labels 显示工具组标签 Auto-Rotate (turn the paper to each artboard) 自动旋转 (为每个画板转动纸张) Automatic flatness 自动平滑度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e273ca6f..3e2be7bf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -215,6 +215,10 @@ Auto Size New Area Type 自動調整新區域文字大小 Auto Spell Check 自動拼字檢查 Auto-Collapse Iconic Panels 自動收合圖示面板 + Ellipse Properties: 橢圓屬性: + Invert Pie 反轉扇形 + Pie End Angle: 扇形終止角度: + Pie Start Angle: 扇形起始角度: Show Tool Group Labels 顯示工具組標籤 Auto-Rotate (turn the paper to each artboard) 自動旋轉 (將紙張轉向各工作區域) Automatic flatness 自動平滑度 diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e91be918..ea27ebc8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -468,6 +468,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { }); corner_radius_row(app, ui, &n, "radius"); } + vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"), _ => {} } } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index bed4c97e..bd3a24d5 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -192,6 +192,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); corner_radius_row(app, ui, &n, "xfp-radius"); } + vectorcraft_doc::LiveShape::Ellipse { pie, .. } => { + widgets::subheader(ui, tl!("Ellipse Properties:")); + pie_rows(app, ui, *pie, "xfp-pie"); + } _ => { widgets::subheader(ui, tl!("Shape Properties:")); widgets::dim_label(ui, tl!(n.kind_label())); @@ -211,6 +215,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// An ellipse's Pie Start and End Angle (degrees, counterclockwise from 3 o'clock; 0 to 360 is the +/// whole ellipse) and Invert Pie, which shows the other part of it (Transform and Properties panels). +pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), id: &str) { + for (label, key, v) in [(tl!("Pie Start Angle:"), "pieStart", pie.0), (tl!("Pie End Angle:"), "pieEnd", pie.1)] { + ui.horizontal(|ui| { + widgets::dim_label(ui, label); + if let Some(a) = widgets::plain_field(ui, (id, key), v, "°", 1, 60.0) { + app.run("object.setLiveShape", json!({ key: a })).ok(); + } + }); + } + if widgets::flat_button(ui, tl!("Invert Pie"), 90.0).clicked() { + app.run("object.setLiveShape", json!({"invertPie": true})).ok(); + } +} + /// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a /// popover under it, as the Stroke link does with the Stroke panel. pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) { From 975d4975ee2a48dd9f620c9a1b3dea0b8ce09a94 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 08:33:59 -0700 Subject: [PATCH 24/43] Swatches: swatch.library.copyToUser copies a loaded library into User Defined swatch.library.copyToUser {library} copies a library loaded from another folder into the user library folder of the desktop app. A .vcswatches, .gpl or .ase file is copied as it is; the swatches of a document and a library loaded from the data or dataBase64 parameter are written as a .vcswatches file. The copy gets a name that no file in the folder has, and when a file with the same extension and bytes is already there, that file is used. The library is then listed as User Defined only. Until the app quits, commands given its loaded id use the copy, unless another library is loaded under that id. Without a user library folder (the web and headless sessions) the command is disabled. A file with the same extension and bytes as a User Defined library opens as that User Defined library. The unchanged original of a copied .ase file is one such file. Graphic style libraries open the same way. --- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/mod.rs | 21 +++++ crates/engine/src/cmd/fileio/tests.rs | 12 +++ crates/engine/src/cmd/package.rs | 2 +- crates/engine/src/cmd/stylelib.rs | 2 +- crates/engine/src/cmd/swatchlib.rs | 117 ++++++++++++++++++++++++-- crates/engine/src/tests_stylelib.rs | 9 +- crates/engine/src/tests_swatchlib.rs | 89 ++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/control-protocol.md | 3 +- docs/mcp.md | 11 ++- 19 files changed, 269 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f18d3a53..44025d5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,7 +153,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7781f69c..f124726f 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -725,6 +725,22 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never +/// replaced. When the write fails part way, the file it started is removed. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> { + use std::io::Write as _; + let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?; + let written = f.write_all(bytes).and_then(|()| f.sync_all()); + drop(f); + written.map_err(|e| { + // Best effort: the partly written file is all there is to clean up. + let _ = std::fs::remove_file(path); + err(e) + }) +} + /// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system /// keeps one); `None` when there is no file at `path`. #[cfg(not(target_arch = "wasm32"))] @@ -786,6 +802,11 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { Err(no_fs(path)) } +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { + Err(no_fs(path)) +} + /// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index cecc9750..a5ef77ab 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -396,3 +396,15 @@ fn oversized_raster_exports_are_refused() { assert!(e.contains("too large"), "{e}"); assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side"); } + +#[test] +fn new_files_never_replace_a_file() { + let d = tmp_dir("new-file"); + let path = d.join("Brand.ase").to_string_lossy().to_string(); + let _ = std::fs::remove_file(&path); + write_new_file(&path, b"first").unwrap(); + let e = write_new_file(&path, b"second").unwrap_err().to_string(); + assert!(e.starts_with(&format!("{path}: ")), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 046db3a0..881ed645 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -32,7 +32,7 @@ type Entry = (String, Vec); /// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most /// desktop file systems do); the name is taken. -fn free_name(name: &str, taken: &mut HashSet) -> String { +pub(crate) fn free_name(name: &str, taken: &mut HashSet) -> String { let p = Path::new(name); let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned()); let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index ab5c791d..d1905142 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Other Library…", ["Window", "Graphic Style Libraries"], None, - "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}", + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}", always, load ), diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 016c0fd2..e9cf43ba 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -3,7 +3,9 @@ //! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library //! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies //! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]). +//! `swatch.library.copyToUser` copies a loaded library into the user library folder. +use std::collections::HashSet; use std::sync::Arc; use serde_json::{Value, json}; @@ -12,8 +14,9 @@ use vectorcraft_color::palette_io::{self, PaletteFormat}; use vectorcraft_color::recolor::Palette; use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches}; -use super::fileio::{create_dir, read_file, write_file}; +use super::fileio::{create_dir, extension, read_file, write_file, write_new_file}; use super::menucmds::squash; +use super::package::free_name; use super::swatch::{map_default_paints, str_list, swatch_json}; use super::*; @@ -69,10 +72,19 @@ pub fn specs() -> Vec { "Other Library…", ["Window", "Swatch Libraries"], None, - "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}", + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}", always, load ), + cmd!( + "swatch.library.copyToUser", + "Copy to User Defined", + ["Window", "Swatch Libraries"], + None, + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + has_user_folder, + copy_to_user + ), ] } @@ -85,6 +97,9 @@ pub struct Libraries { /// there are no User Defined libraries. user_dir: Option, extra: Vec>, + /// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the + /// loaded id and the User Defined id [`Libraries::get`] finds for it. + copied: Vec<(String, String)>, } #[derive(Clone, Debug)] @@ -153,9 +168,11 @@ impl Libraries { self.extra.iter().map(|e| &e.info) } - /// The User Defined or loaded library `id`. + /// The User Defined or loaded library `id`. The id of a loaded library copied into the user + /// library folder finds the User Defined copy. pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc)> { - self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone())) + let find = |id: &str| self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone())); + find(id).or_else(|| self.copied.iter().find(|(loaded, _)| loaded == id).and_then(|(_, user)| find(user))) } /// The file library `id` was read from. @@ -163,6 +180,14 @@ impl Libraries { self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref()) } + /// The User Defined library whose file has the extension of file name `file` and holds `bytes`. + fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra> { + let ext = extension(file); + self.extra + .iter() + .find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes))) + } + /// Write library file `data` as the save commands do: into the user library folder as /// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as /// `data` (text) or `dataBase64` (bytes); the results go into `out`. @@ -196,8 +221,9 @@ impl Libraries { } /// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the - /// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is - /// its User Defined library; others are listed as loaded until the app quits. + /// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder, + /// or a file with the same extension and bytes as one there, is that User Defined library; + /// others are listed as loaded until the app quits. pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result) -> Result<(LibraryInfo, Arc)> { let path = str_param(p, "path"); let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { @@ -209,7 +235,9 @@ impl Libraries { let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); let lib = parse(&bytes, &file)?; self.rescan(); - if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) { + let user = + |p: &str| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p)).or_else(|| self.user_file(p, &bytes)); + if let Some(e) = path.and_then(user) { return Ok((e.info.clone(), e.lib.clone())); } let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" }; @@ -218,6 +246,58 @@ impl Libraries { self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() }); Ok((info, lib)) } + + /// Copy loaded library `id` into the user library folder, where it is listed as User Defined in + /// place of the loaded entry; `id` then finds the copy ([`Libraries::get`]). A library read + /// from a library file (an extension of `L::EXTS`) is copied as that file's bytes under the + /// file's name; any other (the swatches of a document, a library loaded from data) is written + /// by `encode` as a `.{ext}` file named after the library. A name the folder already has, + /// compared without case, gets a number ("Brand 2.ase"), and no file is replaced. When a file + /// of the folder with the same extension holds the same bytes, its library is used and nothing + /// is written. → the User Defined library, and whether a file was written. + pub(crate) fn copy_to_user( + &mut self, + id: &str, + ext: &str, + encode: impl FnOnce(&L) -> Result>, + cmd: &str, + ) -> Result<(LibraryInfo, Arc, bool)> { + let dir = self.user_dir().ok_or_else(|| bad(cmd, "no user library folder here"))?.to_string(); + let (source, lib) = self + .extra + .iter() + .find(|e| e.info.id == id && e.info.category == "loaded") + .map(|e| (e.path.clone(), e.lib.clone())) + .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; + let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { + Some(p) => (file_name(&p), read_file(&p)?), + None => (library_file_name(lib.name(), ext), encode(&lib)?), + }; + self.rescan(); + let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) { + Some(same) => (same, None), + None => { + create_dir(&dir)?; + let mut taken: HashSet = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect(); + let file = free_name(&file, &mut taken); + let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string(); + write_new_file(&path, &bytes)?; + self.rescan(); + (format!("user/{file}"), Some(path)) + } + }; + let Some((info, lib)) = self.get(&user_id) else { + if let Some(path) = &written { + // Best effort: the copy that can't be read is all there is to clean up. + let _ = std::fs::remove_file(path); + } + return Err(bad(cmd, format!("the copy `{user_id}` can't be read as a library"))); + }; + self.extra.retain(|x| x.info.id != id); + self.copied.retain(|(loaded, _)| loaded != id); + self.copied.push((id.to_string(), info.id.clone())); + Ok((info, lib, written.is_some())) + } } /// The file name of library `name` with extension `ext`: characters file systems reject become @@ -405,6 +485,29 @@ fn get(s: &mut Session, p: &Value) -> Result { ) } +/// There is a user library folder to copy libraries into (the desktop app sets one). +fn has_user_folder(s: &Session) -> std::result::Result<(), String> { + match s.swatch_libraries.user_dir() { + Some(_) => Ok(()), + None => Err("no user library folder here".into()), + } +} + +fn copy_to_user(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.library.copyToUser"; + let (info, lib) = library_param(s, p, C)?; + let (info, lib, copied) = match info.category { + "loaded" => { + let native = |lib: &SwatchLibrary| palette_io::write(lib, PaletteFormat::Native).map_err(|e| bad(C, e)); + s.swatch_libraries.copy_to_user(&info.id, PaletteFormat::Native.id(), native, C)? + } + "user" => (info, lib, false), + _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), + }; + let path = s.swatch_libraries.path(&info.id); + Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) +} + /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: /// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all. fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result, &'a Swatch)>> { diff --git a/crates/engine/src/tests_stylelib.rs b/crates/engine/src/tests_stylelib.rs index 927f81f8..4ab70936 100644 --- a/crates/engine/src/tests_stylelib.rs +++ b/crates/engine/src/tests_stylelib.rs @@ -192,8 +192,15 @@ fn user_libraries_and_other_documents_styles() { assert_eq!(user.len(), 1); assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64))); assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref())); - // Opening a file of the user folder gives its User Defined library. + // Opening a file of the user folder gives its User Defined library, and so does a file with the + // same bytes in another folder. assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles"); + let elsewhere = dir.with_file_name(format!("vc-stylelib-elsewhere-{}", std::process::id())); + std::fs::create_dir_all(&elsewhere).unwrap(); + let copy = elsewhere.join("Looks.vcstyles"); + std::fs::copy(&path, ©).unwrap(); + assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": copy.to_string_lossy() }))["library"], "user/My- Looks.vcstyles"); + let _ = std::fs::remove_dir_all(elsewhere); let _ = std::fs::remove_dir_all(dir); // Another document's graphic styles load as a library. diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index cde437f8..68f1d8ec 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -283,6 +283,95 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() { + use vectorcraft_testkit::ase::{Block, ase, sample}; + let dir = temp_dir("copy"); + let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere")); + std::fs::create_dir_all(&user).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + let source = elsewhere.join("Brand.ase"); + std::fs::write(&source, sample()).unwrap(); + let source = source.to_string_lossy().to_string(); + // Another library whose file name differs only in case. + let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]); + std::fs::write(user.join("brand.ase"), &other).unwrap(); + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string())); + let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone(); + assert_eq!(loaded, format!("loaded/{source}")); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + // A swatch exchange library is named after its file. + assert_eq!( + (r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()), + (Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true)) + ); + assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string()); + assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is"); + assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced"); + let listed = |s: &mut Session| -> Vec { + let list = run(s, "swatch.library.list", json!({})); + let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded"))); + libs.map(|l| l["id"].as_str().unwrap().to_string()).collect() + }; + assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only"); + // The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors. + let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id); + assert_eq!(found.as_deref(), Some("user/Brand 2.ase")); + assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some()); + // The same file again opens the copy, which is listed once; copying it writes nothing. + let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone(); + assert_eq!(again, "user/Brand 2.ase"); + assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again })); + assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false))); + assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2); + // A document's swatches and a library loaded from data are written as .vcswatches. + run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"})); + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let poster = elsewhere.join("Poster.vectorcraft"); + std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone(); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true))); + assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some()); + // Copied again, the document's swatches are written as the same bytes: the copy there is used. + let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone(); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false))); + let kit = vectorcraft_format::base64_encode(&sample()); + let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone(); + assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches"); + // A library without a name is copied as Library. + let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone(); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true))); + // When the source no longer reads as a library, the copy fails and no new file is left in the folder. + let junk = elsewhere.join("Junk.ase"); + std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap(); + let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone(); + std::fs::write(&junk, b"not a library").unwrap(); + let files = std::fs::read_dir(&user).unwrap().count(); + let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string(); + assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}"); + assert_eq!(std::fs::read_dir(&user).unwrap().count(), files); + // A User Defined library comes back as it is; a built-in one is an error. + let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"})); + assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false))); + assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in")); + assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err()); + // Without a user library folder (the web, headless sessions) there is nowhere to copy to, and + // the command is off. + let mut headless = session(); + let info = find_command("swatch.library.copyToUser").unwrap().info(&headless); + assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}"); + assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled); + let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone(); + let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string(); + assert!(e.contains("no user library folder"), "{e}"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_library_group_stays_one_group_when_a_swatch_has_its_name() { use vectorcraft_testkit::ase::{Block, ase}; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2af7daac..81107a18 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -493,6 +493,7 @@ Copy Fonts Used in the Document Copiar fuentes usadas en el documento Copy Links Copiar vínculos Copy Selected Style Copiar estilo seleccionado + Copy to User Defined Copiar en Definido por el usuario Copy Width Point Copiar punto de anchura Copyright Copyright Copyright Info URL: URL de información de copyright: @@ -3454,6 +3455,7 @@ axis Both Ambos @msg no symbol instances selected no hay instancias de símbolo seleccionadas @msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id` @msg no threaded text selected no hay texto enlazado seleccionado +@msg no user library folder here aquí no hay ninguna carpeta de bibliotecas de usuario @msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}` @msg node {_1} is not text el nodo {_1} no es texto @msg not a DXF drawing no es un dibujo DXF diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 849006c6..d0f2a1b8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -494,6 +494,7 @@ Copy Fonts Used in the Document Copier les polices utilisées dans le document Copy Links Copier les liens Copy Selected Style Copier le style sélectionné + Copy to User Defined Copier dans Défini par l’utilisateur Copy Width Point Copier le point de largeur Copyright Copyright Copyright Info URL: URL des informations de copyright : @@ -3451,6 +3452,7 @@ axis Both Les deux @msg no symbol instances selected aucune occurrence de symbole sélectionnée @msg no target object has an opacity mask: select one or give `id` aucun objet ciblé n’a de masque d’opacité : sélectionnez-en un ou indiquez `id` @msg no threaded text selected aucun texte lié sélectionné +@msg no user library folder here aucun dossier de bibliothèques utilisateur ici @msg no user workspace `{from}` aucun espace de travail utilisateur `{from}` @msg node {_1} is not text le nœud {_1} n’est pas du texte @msg not a DXF drawing ce n’est pas un dessin DXF diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5a4e89bf..bdceca82 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -493,6 +493,7 @@ Copy Fonts Used in the Document Copia i font usati nel documento Copy Links Copia collegamenti Copy Selected Style Copia stile selezionato + Copy to User Defined Copia in Definito dall'utente Copy Width Point Copia punto di larghezza Copyright Copyright Copyright Info URL: URL informazioni copyright: @@ -3454,6 +3455,7 @@ axis Both Entrambi @msg no symbol instances selected nessuna istanza di simbolo selezionata @msg no target object has an opacity mask: select one or give `id` nessun oggetto di destinazione ha una maschera di opacità: selezionane uno o specifica `id` @msg no threaded text selected nessun testo concatenato selezionato +@msg no user library folder here nessuna cartella di librerie utente qui @msg no user workspace `{from}` nessuna area di lavoro utente `{from}` @msg node {_1} is not text il nodo {_1} non è testo @msg not a DXF drawing non è un disegno DXF diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fd96d775..ec765417 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -978,6 +978,7 @@ Copy All Styles すべてのスタイルをコピー Copy Color Value (Hex) カラー値をコピー (16 進) Copy Selected Style 選択したスタイルをコピー + Copy to User Defined ユーザー定義にコピー Copy Width Point 幅ポイントをコピー Copyright 著作権 Copyright Info URL: 著作権情報の URL: @@ -3682,6 +3683,7 @@ theme Appearance 外観 @msg Unembedded to {_1} 埋め込みを解除して {_1} に保存しました @msg Updated {_1} image(s) 画像を {_1} 枚更新しました @msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照) +@msg no user library folder here この環境にはユーザーライブラリフォルダーがありません @msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています @msg no files to place 配置するファイルがありません @msg paths: give at least one file paths: ファイルを 1 つ以上指定してください diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fd4bbb4a..376d6e01 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -480,6 +480,7 @@ Copy Color Value (Hex) Copiar Valor da Cor (Hex) Copy Selected Style Copiar Estilo Selecionado Copy Width Point Copiar Ponto de Largura + Copy to User Defined Copiar para Definido pelo Usuário Copyright Direitos Autorais Copyright Info URL: URL de Informações de Direitos Autorais: Copyright Notice: Aviso de Direitos Autorais: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 66a53611..0fcb89fa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -491,6 +491,7 @@ Copy Fonts Used in the Document Копировать шрифты, использованные в документе Copy Links Копировать связи Copy Selected Style Копировать выбранный стиль + Copy to User Defined Копировать в «Пользовательский» Copy Width Point Копировать точку ширины Copyright Copyright Copyright Info URL: URL информации об авторских правах: @@ -3450,6 +3451,7 @@ axis Both Оба @msg no symbol instances selected экземпляры символов не выделены @msg no target object has an opacity mask: select one or give `id` ни у одного целевого объекта нет непрозрачной маски: выделите один или укажите `id` @msg no threaded text selected связанный текст не выделен +@msg no user library folder here здесь нет папки пользовательских библиотек @msg no user workspace `{from}` нет пользовательской рабочей среды `{from}` @msg node {_1} is not text узел {_1} не является текстом @msg not a DXF drawing не является чертежом DXF diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b3fe25ae..78a1298a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -487,6 +487,7 @@ Copy Fonts Used in the Document Копіювати шрифти, використані у документі Copy Links Копіювати зв’язки Copy Selected Style Копіювати вибраний стиль + Copy to User Defined Копіювати в «Користувацькі» Copy Width Point Копіювати точку ширини Copyright Авторські права Copyright Info URL: URL інформації про авторські права: @@ -3437,6 +3438,7 @@ axis Both Обидві @msg no symbol instances selected екземпляри символів не виділені @msg no target object has an opacity mask: select one or give `id` у жодного цільового об’єкта немає маски непрозорості: виділіть один або вкажіть `id` @msg no threaded text selected пов’язаний текст не виділено +@msg no user library folder here тут немає папки користувацьких бібліотек @msg no user workspace `{from}` немає користувача робочого середовища `{from}` @msg node {_1} is not text вузол {_1} не є текстом @msg not a DXF drawing не є кресленням DXF diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fb53152a..9b6e0f84 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -477,6 +477,7 @@ Copy Fonts Used in the Document 复制文档中使用的字体 Copy Links 复制链接 Copy Selected Style 复制所选样式 + Copy to User Defined 复制到用户定义 Copy Width Point 复制宽度点 Copyright 版权 Copyright Info URL: 版权信息 URL: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e273ca6f..1e206e80 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -491,6 +491,7 @@ Copy Fonts Used in the Document 複製文件中使用的字體 Copy Links 複製連結 Copy Selected Style 複製所選樣式 + Copy to User Defined 複製到使用者定義 Copy Width Point 複製寬度點 Copyright 版權 Copyright Info URL: 著作權資訊網址: diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cd9aec03..2a897986 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -154,7 +154,8 @@ Clicking a swatch there runs `swatch.library.add {library, names: [name], apply} one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for Add to Swatches. `window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there; -opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the +opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. A file with the same extension and +bytes as a User Defined library opens as that library. `ui.saveSwatchLibrary {names?}` opens the `saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`ase`/`css`, `user`: save to the user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path and suggests `name` with the format's extension as the file name; the web downloads the file under that name). diff --git a/docs/mcp.md b/docs/mcp.md index 6a7416c9..a673e4f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -804,7 +804,13 @@ it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into t folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | dataBase64?, name?}` loads a `.vcswatches`, `.gpl` or swatch exchange (`.ase`) file, or another document's swatches, as a library to add from. From an `.ase` file it reads RGB, CMYK, Lab and Gray colors as global, spot or process -swatches and keeps their color groups. +swatches and keeps their color groups. A file in the user library folder, or a file with the +same extension and bytes as one there, loads as that User Defined library (category `user`). +`swatch.library.copyToUser {library}` copies a loaded library into the user library folder of the desktop +app (a library file as it is; a document's swatches or a library loaded from `data` or `dataBase64` +as `.vcswatches`) under a name no file there has, and lists it as User Defined from then on; until the app +quits, commands given its loaded id use the copy → `{library, name, count, path, copied}` (`copied: false` +when the folder already held the same file). Without a user library folder the command is disabled. ## Graphic style libraries @@ -827,7 +833,8 @@ its bounds. the styles unlinked from swatches, with their opacity, blend mode, isolate and knockout, and the patterns they paint with); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app (category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles` -file, or another document's graphic styles, as a library to add from. +file, or another document's graphic styles, as a library to add from; a file in the user library folder, or a file +with the same extension and bytes as one there, loads as that User Defined library. ## Libraries From 4181755db6314e5393ad819005c4f2659e7262ae Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:02:48 +0700 Subject: [PATCH 25/43] Hyphenation exceptions: unit tests read only their thread's list hyphenation_exceptions_override_the_pattern failed when the round-trip test set the process-wide list at the same moment: words missing from the thread's override fell through to the shared store. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/hyphen.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index 93acd4ba..120d7e9c 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -90,17 +90,18 @@ fn normalize_word(word: &str) -> String { word.chars().flat_map(|c| c.to_lowercase()).collect() } -/// Look the word up under the preferences lock (or the thread-local test override). Only the matched -/// [`Exception`] is cloned, never the whole list. +/// Look the word up under the preferences lock. Only the matched [`Exception`] is cloned, never +/// the whole list. +#[cfg(not(test))] fn exception_for(word: &str) -> Option { - let key = normalize_word(word); - #[cfg(test)] - { - if let Some(ex) = TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&key).cloned())) { - return Some(ex); - } - } - store().map.get(&key).cloned() + store().map.get(&normalize_word(word)).cloned() +} + +/// Unit tests read only their own thread's list: tests running in parallel set the process-wide +/// one. +#[cfg(test)] +fn exception_for(word: &str) -> Option { + TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&normalize_word(word)).cloned())) } fn is_vowel(c: char) -> bool { From 72c9cec0e0f24c3f3fe2ce33345c8b4665854a2f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:03:04 +0700 Subject: [PATCH 26/43] Fix #847: draw Underline and Strikethrough The styles were stored on the text and written to SVG's text-decoration, but the type was drawn without the lines. vectorcraft_text::decorations now gives each underlined or struck-through stretch of a run on a line its bar, placed and sized from the font's own underline and strikeout metrics (with fallbacks for fonts that have none). The canvas, Create Outlines, PDF export (outlined and real text) and SVG's outlined text draw them in the run's paint. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/typecmd.rs | 7 ++++++ crates/pdf/src/export.rs | 16 ++++++++++++- crates/render/src/lib.rs | 7 ++++++ crates/svg/src/export.rs | 22 ++++++++++++------ crates/text/src/fontdb.rs | 17 ++++++++++++-- crates/text/src/layout.rs | 40 ++++++++++++++++++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/tests.rs | 23 ++++++++++++++++++ 8 files changed, 123 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index a9921c6e..4d985a44 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { node.appearance = st.appearance(); children.push(Arc::new(node)); } + // Underline and strikethrough bars become paths of their run's paint too (#847). + for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), &t) { + let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); + let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None); + node.appearance = st.appearance(); + children.push(Arc::new(node)); + } let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?; d.remove(tid)?; if children.is_empty() { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d59dbe4c..d0ed2471 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1304,6 +1304,9 @@ impl Exporter<'_> { let (below, above) = n.appearance.split_contents(); let all = (!n.appearance.items.is_empty()).then(|| { let mut all = layout.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) { + all.extend(bar.iter()); + } all.apply_affine(t.xf); to_path(&all).map(|p| (all, p)) }); @@ -1314,16 +1317,27 @@ impl Exporter<'_> { s.push_transform(&xf(t.xf)); // Real text: the characters' fills as text in embedded fonts. let plain = (!self.outline_text).then(|| t.plain_text()); + let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); for (i, run) in t.runs.iter().enumerate() { let mut bp = BezPath::new(); for g in layout.glyphs.iter().filter(|g| g.run == i) { bp.extend(g.outline.iter()); } + // Underline and strikethrough bars are paths, with real text too (#847). + let mut run_bars = BezPath::new(); + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + run_bars.extend(bar.iter()); + } + bp.extend(run_bars.iter()); let fill = &run.style.fill; self.overprinting(s, run.style.overprint_fill, fill, |ex, s| { // The outlines left to fill as paths: all of them, or those real text leaves. let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) { - Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)), + Some(text) => { + let mut left = ex.glyph_text(s, &layout, i, text, fill); + left.extend(run_bars.iter()); + Cow::Owned(left) + } None => Cow::Borrowed(&bp), }; let opaque = (1.0, BlendMode::Normal); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3ca4de81..9a9899a7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1680,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); target.extend(cell.iter()); } + // Underline and strikethrough bars, painted as their run's type is (#847). + for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { + if let Some(r) = runs.get_mut(run) { + r.extend(bar.iter()); + } + all.extend(bar.iter()); + } TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index a67d91c4..7fc8c327 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1638,8 +1638,13 @@ impl Writer<'_> { let a = self.attrs(&css::transparency(n)); self.line(&format!("")); self.depth += 1; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf); + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = lay.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let pd = PathData::from_bezpath(&all).transformed(t.xf); let d = self.path_d(&pd, self.xf); let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); let (below, above) = n.appearance.split_contents(); @@ -1671,12 +1676,15 @@ impl Writer<'_> { /// Text as glyph outlines: one compound path per run, painted like the run (gradients span /// the whole text, as on the canvas). fn text_outlines(&mut self, n: &Node, t: &TextObject) { - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; - for g in &lay.glyphs { - match runs.last_mut() { - Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()), - _ => runs.push((g.run, g.outline.clone())), + // Underline and strikethrough bars join their run's outlines. + let bars = vectorcraft_text::decorations(&lay, db, t); + for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) { + match runs.iter_mut().find(|(r, _)| *r == run) { + Some((_, bp)) => bp.extend(outline.iter()), + None => runs.push((run, outline.clone())), } } let id = self.id_attr(n); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 74aec355..edd3bcdd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -72,6 +72,10 @@ pub struct FontFace { /// values). pub(crate) cap_height: f64, pub(crate) x_height: f64, + /// Underline and strikeout: (offset from the baseline up to the line's top, thickness), in + /// font units (the `post` and OS/2 tables', else a common placement). + pub(crate) underline: (f64, f64), + pub(crate) strikeout: (f64, f64), pub(crate) shaper: harfrust::ShaperData, /// The axis settings of the variable font's named instance this face is (user units: `wght` /// 700); empty for a static face or a variable font's default instance. @@ -1048,6 +1052,13 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option 0.0).unwrap_or(m.ascent as f64 * 0.5); + let decoration = |d: Option, fallback: (f64, f64)| { + d.map(|d| (f64::from(d.offset), f64::from(d.thickness))).filter(|(o, t)| o.is_finite() && *t > 0.0 && t.is_finite()).unwrap_or(fallback) + }; + let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem)); + let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem)); Some(FontFace { keys, traits, @@ -1060,11 +1071,13 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option 0.0).unwrap_or(m.ascent as f64 * 0.72), - x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + x_height, + underline, + strikeout, shaper, bytes, index, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 73de86f1..bba0a7c1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -146,6 +146,46 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { layout_with(db, t, &opts) } +/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a +/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's +/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift +/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet. +pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> { + let mut out = vec![]; + if layout.on_path || layout.vertical { + return out; + } + let glyphs = &layout.glyphs; + let mut i = 0; + while let Some(g) = glyphs.get(i) { + // The stretch: the glyphs of this run on this line, in a row. + let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n); + let stretch = glyphs.get(i..end).unwrap_or_default(); + i = end.max(i + 1); + let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + if !(style.underline || style.strikethrough) { + continue; + } + let Some(face) = db.face_by_id(g.font_id) else { continue }; + let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| { + let (s, e) = (h.origin.x, h.origin.x + h.advance); + (a.min(s).min(e), b.max(s).max(e)) + }); + for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] { + if !on { + continue; + } + // Font units run y down in the glyph's transform: the bar's top is at -offset. + let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y); + let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom)); + if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() { + out.push((g.run, bar.to_path(0.1))); + } + } + } + out +} + /// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). const SHRINK_PASSES: usize = 11; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 0c94e5bc..1115df74 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -32,7 +32,7 @@ pub use fontdb::{ }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; -pub use layout::{layout, layout_with}; +pub use layout::{decorations, layout, layout_with}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 35339fe4..6dc4e3d7 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -138,6 +138,29 @@ fn tracking_and_scale() { assert!(b.y1 < -4.0, "shifted up: {b:?}"); } +#[test] +fn underline_and_strikethrough_bars() { + let mut t = point("ab", style(20.0)); + t.runs = vec![ + TextRun { text: "ab".into(), style: style(20.0), inline: None }, + TextRun { text: "cd".into(), style: CharStyle { underline: true, strikethrough: true, ..style(20.0) }, inline: None }, + ]; + let l = layout(db(), &t); + let bars = decorations(&l, db(), &t); + assert_eq!(bars.len(), 2, "one underline and one strikethrough: {bars:?}"); + assert!(bars.iter().all(|(run, _)| *run == 1), "only the styled run"); + let (under, strike) = (bars[0].1.bounding_box(), bars[1].1.bounding_box()); + // The bars span the run's glyphs: from "c" to the end of "d". + let (c, d) = (&l.glyphs[2], &l.glyphs[3]); + assert!((under.x0 - c.origin.x).abs() < 1e-6 && (under.x1 - d.origin.x - d.advance).abs() < 1e-6, "{under:?}"); + // y runs down: the underline sits below the baseline, the strikethrough above it, both thin. + assert!(under.y0 > 0.0 && under.y1 < 5.0, "{under:?}"); + assert!(strike.y1 < 0.0 && strike.y0 > -10.0, "{strike:?}"); + assert!(under.height() < 3.0 && strike.height() < 3.0); + // No bars for plain text. + assert!(decorations(&layout(db(), &point("ab", style(20.0))), db(), &point("ab", style(20.0))).is_empty()); +} + #[test] fn all_caps() { let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) })); From 77a3cef4d14242349d2a14f0ae01a18b0080b32d Mon Sep 17 00:00:00 2001 From: Reinhard Schuerer Date: Sat, 10 Oct 2026 01:08:33 +0200 Subject: [PATCH 27/43] Draw on the GPU that drives the primary display on Windows and Linux On a desktop with a Ryzen integrated GPU without a monitor and an NVIDIA card driving both monitors, Automatic (power saving on Windows) picked the integrated GPU; Windows presented every frame across adapters and the integrated GPU's driver reset, losing the graphics device or taking every monitor down for minutes (same bug as pdfcraft#378). Automatic now asks the system which GPU drives which display (Windows: EnumDisplayDevices through winsafe; Linux: /sys/class/drm) and ranks the GPU driving a display, and among those the primary display's, ahead of the power preference. Where the system can't say, or no adapter's PCI ids match (OpenGL, Metal), the order is the power preference's as before, so nothing gets worse. Power Saving and High Performance chosen by the user, and WGPU_POWER_PREF, keep ordering by kind alone. The log now lists the display GPUs, which adapters drive a display, and the adapter drawn on with the reason, the first question in every black-window report. Tests cover the device-id parser, the rankings (desktop with all monitors on the discrete GPU, hybrid laptop, a monitor on each GPU, no match), the sysfs reader and a live EnumDisplayDevices call. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch --- Cargo.lock | 7 + apps/vectorcraft/Cargo.toml | 6 +- apps/vectorcraft/src/gpu.rs | 441 ++++++++++++++++++++++++++++++++--- apps/vectorcraft/src/main.rs | 18 +- docs/development.md | 13 +- 5 files changed, 450 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2967ad3..58dafc03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4344,6 +4344,7 @@ dependencies = [ "windows", "winit", "winresource", + "winsafe", ] [[package]] @@ -5744,6 +5745,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index bb6469a7..35588432 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [features] default = ["wgpu", "accessibility"] -wgpu = ["eframe/wgpu"] +wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] # Build with --no-default-features --features windows7 and the win7 Rust target. windows7 = ["eframe/glow"] @@ -36,6 +36,10 @@ winit = { version = "0.30", default-features = false } [target.'cfg(windows)'.dependencies] clipboard-win = { workspace = true } +# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the +# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over +# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter. +winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true } # DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as # Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit. diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 5330a9ba..d2044e0d 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -4,10 +4,21 @@ //! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any //! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that //! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware -//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference +//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU that drives a +//! display and the one that drives the primary display ([`display_gpus`]), the power preference //! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545). //! eframe is handed [`selector`], which takes the first of that order. //! +//! A GPU without a monitor is the wrong one even when it can present: on a desktop whose monitors +//! all hang off the discrete GPU, power saving picked the Ryzen's integrated GPU, Windows had to +//! present every frame across adapters, and the integrated GPU's driver reset under that, which +//! lost the graphics device, or took the desktop's compositor and every monitor down for minutes +//! (pdfcraft#378). So, when Preferences say Automatic, the GPU that drives the display the user +//! looks at comes first, and the power preference only orders the rest. Windows says which adapter +//! drives which display (`EnumDisplayDevices`), Linux through sysfs; elsewhere, or when nothing +//! matches, the order is the power preference's as before. Power Saving and High Performance, +//! chosen by the user, order by kind as they always did. +//! //! An adapter can report that it presents to the window and still fail once it does: on a hybrid //! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers //! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while @@ -58,6 +69,18 @@ pub fn power_preference(pref: Option<&str>, env: Option) -> Pow } } +/// Whether the adapter is the app's to choose: neither `WGPU_POWER_PREF` nor Preferences name a +/// kind of GPU (the arguments of [`power_preference`]). Then the GPU that drives the display comes +/// first ([`display_gpus`]); a user's own choice is kept as it is. +pub fn automatic(pref: Option<&str>, env: Option) -> bool { + env.is_none() && !matches!(pref, Some("highPerformance" | "lowPower")) +} + +/// The GPUs to rank first: those that drive a display, when the choice is [`automatic`]. +pub fn preferred_displays(pref: Option<&str>, env: Option) -> Vec { + if automatic(pref, env) { display_gpus() } else { Vec::new() } +} + /// One adapter, as [`adapter_order`] sees it. #[derive(Clone, Debug)] pub struct Candidate { @@ -66,6 +89,9 @@ pub struct Candidate { pub name: String, pub backend: Backend, pub device_type: DeviceType, + /// PCI vendor and device ids, to match a [`DisplayGpu`]; zero where the backend reports none + /// (Metal, and OpenGL's device id). + pub pci: (u32, u32), /// Reports that it can present to the window's surface. pub presents: bool, } @@ -79,8 +105,128 @@ impl Candidate { name: info.name, backend: info.backend, device_type: info.device_type, + pci: (info.vendor, info.device), } } + + /// Whether it drives one of `displays`, and whether that is the primary one. + fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) { + let shown = displays.iter().find(|g| g.pci == self.pci); + (shown.is_some(), shown.is_some_and(|g| g.primary)) + } +} + +/// A GPU with a monitor attached, as the system reports it ([`display_gpus`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DisplayGpu { + /// PCI `(vendor, device)` ids, as wgpu's `AdapterInfo` reports them. + pub pci: (u32, u32), + /// Drives the display the user most likely looks at: on Windows the primary display, on Linux + /// a built-in panel (`eDP`, `LVDS` or `DSI`). + pub primary: bool, +} + +impl std::fmt::Display for DisplayGpu { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:04x}:{:04x}{}", self.pci.0, self.pci.1, if self.primary { " (primary)" } else { "" }) + } +} + +/// The GPUs that drive a display, each once. Windows enumerates its display devices +/// (`EnumDisplayDevices`: those attached to the desktop, and the primary one); Linux reads the +/// connected connectors in `/sys/class/drm`. macOS and the rest report nothing, so the power +/// preference decides as before; so does a system whose GPUs report no PCI ids. +pub fn display_gpus() -> Vec { + #[cfg(windows)] + { + windows_display_gpus() + } + #[cfg(target_os = "linux")] + { + linux_display_gpus(std::path::Path::new("/sys/class/drm")) + } + #[cfg(not(any(windows, target_os = "linux")))] + { + Vec::new() + } +} + +/// Record that the GPU `pci` drives a display, merging it with its other displays. +fn add_display_gpu(gpus: &mut Vec, pci: (u32, u32), primary: bool) { + match gpus.iter_mut().find(|g| g.pci == pci) { + Some(gpu) => gpu.primary |= primary, + None => gpus.push(DisplayGpu { pci, primary }), + } +} + +/// Windows' display devices: one per GPU output; those `ATTACHED_TO_DESKTOP` show a monitor, one +/// of them the `PRIMARY_DEVICE`. The registry doesn't tell a user which adapter drives a display, +/// and WMI reports a resolution for a GPU without a monitor, so this is the way. +#[cfg(windows)] +fn windows_display_gpus() -> Vec { + use winsafe::co::DISPLAY_DEVICE as Flags; + let mut gpus = Vec::new(); + // A machine has a handful of display devices; the cap only bounds a runaway enumeration. + for device in winsafe::EnumDisplayDevices(None, None).take(256) { + let device = match device { + Ok(d) => d, + // The enumeration ends with "no more items" or, from a driver, with any other error. + Err(e) => { + log::debug!("display devices: {e}"); + break; + } + }; + if !device.StateFlags.has(Flags::ATTACHED_TO_DESKTOP) { + continue; + } + // Remote Desktop and other non-PCI devices (`ROOT\BasicDisplay\0000`) are left out. + let Some(pci) = pci_ids(&device.DeviceID()) else { continue }; + add_display_gpu(&mut gpus, pci, device.StateFlags.has(Flags::PRIMARY_DEVICE)); + } + gpus +} + +/// The PCI `(vendor, device)` of a Windows device id such as +/// `PCI\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1`, any case; `None` for anything else. +fn pci_ids(device_id: &str) -> Option<(u32, u32)> { + let field = |key: &str| { + device_id.split(['\\', '&']).find_map(|part| { + let (name, hex) = part.split_at_checked(key.len())?; + if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None } + }) + }; + Some((field("VEN_")?, field("DEV_")?)) +} + +/// Linux: every connector `cardN-CONNECTOR` under `drm` whose `status` is `connected`, with the +/// PCI ids of its card (`cardN/device/{vendor,device}`). A built-in panel is the primary display. +#[cfg(any(target_os = "linux", test))] +fn linux_display_gpus(drm: &std::path::Path) -> Vec { + let read_hex = |p: std::path::PathBuf| -> Option { + let s = std::fs::read_to_string(p).ok()?; + u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() + }; + let mut gpus = Vec::new(); + let Ok(entries) = std::fs::read_dir(drm) else { return gpus }; + // A machine has a handful of connectors; the cap only bounds a pathological sysfs. + for entry in entries.flatten().take(256) { + let name = entry.file_name(); + let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue }; + let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected"); + if !connected { + continue; + } + let device = drm.join(card).join("device"); + let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue }; + add_display_gpu(&mut gpus, (v, d), is_internal_panel(connector)); + } + gpus +} + +/// A laptop's built-in panel: an embedded DisplayPort, LVDS or DSI connector. +#[cfg(any(target_os = "linux", test))] +fn is_internal_panel(connector: &str) -> bool { + ["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind)) } /// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them). @@ -97,17 +243,30 @@ fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String { /// The order to try `candidates` in (their indices): only those that present to the window and /// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before -/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the -/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU -/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL -/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged. -pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec { +/// software; native backends before OpenGL; the GPUs that drive one of `displays`, the primary +/// display's first; then by `power`; then DX12 before Vulkan, keeping the system's order among +/// equals (all of it for [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's +/// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545); +/// elsewhere there is no DX12 adapter, so the order is unchanged. +/// +/// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no +/// ids at all), the order is the power preference's alone, as it was. +pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: &[DisplayGpu], named: Option<&str>, skip: &[String]) -> Vec { let named = named.map(str::to_lowercase).filter(|n| !n.is_empty()); let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect(); // Stable, so equals keep the system's order. order.sort_by_key(|(_, c)| { let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n)); - (unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan) + let (drives_display, drives_primary) = c.drives(displays); + ( + unnamed, + c.device_type == DeviceType::Cpu, + c.backend == Backend::Gl, + !drives_display, + !drives_primary, + power_rank(c.device_type, power), + c.backend == Backend::Vulkan, + ) }); order.into_iter().map(|(i, _)| i).collect() } @@ -151,21 +310,27 @@ impl Startup { } } -/// eframe's adapter choice: the first of [`adapter_order`] for `power`, `WGPU_ADAPTER_NAME` and the -/// adapters a restart left out. Every adapter and the choice are logged, for bug reports. -pub fn selector(power: PowerPreference, startup: Arc) -> NativeAdapterSelectorMethod { +/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a +/// `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart left out. Every adapter, the displays +/// and the choice are logged: which GPU draws is the first question in every black-window report. +pub fn selector(power: PowerPreference, displays: Vec, startup: Arc) -> NativeAdapterSelectorMethod { let named = std::env::var(NAME_ENV).ok(); let skip = skipped(); Arc::new(move |adapters, surface| { let candidates: Vec = adapters.iter().map(|a| Candidate::new(a, surface)).collect(); - let order = adapter_order(&candidates, power, named.as_deref(), &skip); + let order = adapter_order(&candidates, power, &displays, named.as_deref(), &skip); let listed: Vec = candidates .iter() .map(|c| { + let (drives_display, drives_primary) = c.drives(&displays); let note = if !c.presents { ", can't show the window" } else if skip.contains(&c.key) { ", failed before" + } else if drives_primary { + ", drives the primary display" + } else if drives_display { + ", drives a display" } else { "" }; @@ -177,6 +342,13 @@ pub fn selector(power: PowerPreference, startup: Arc) -> NativeAdapterS let Some((adapter, chosen)) = first else { return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len())); }; + let why = match chosen.drives(&displays) { + (_, true) => "it drives the primary display".to_string(), + (true, false) => "it drives a display".to_string(), + (false, false) if displays.is_empty() => format!("power preference {power:?}"), + (false, false) => format!("no adapter matches a display GPU, so power preference {power:?}"), + }; + log::info!("drawing on {} ({:?}, {:?}): {why}", chosen.name.trim(), chosen.backend, chosen.device_type); // Set once; eframe picks the adapter once. let _ = startup.adapter.set(chosen.key.clone()); Ok(adapter.clone()) @@ -248,13 +420,222 @@ mod tests { use super::*; fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { - Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, presents: true } + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } } fn names(c: &[Candidate], order: &[usize]) -> Vec { order.iter().map(|&i| c[i].name.clone()).collect() } + const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); + const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + const WARP: (u32, u32) = (0x1414, 0x008c); + + fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } + } + + fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } + } + + fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } + } + + /// The desktop of pdfcraft#378 as wgpu lists it on Windows: a Ryzen's integrated GPU without a + /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and + /// OpenGL (which reports no device id). + fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] + } + + /// pdfcraft#378: power saving picked the integrated GPU, whose driver reset when Windows had to + /// present its frames on the monitors of the NVIDIA card. The GPU that drives the displays now + /// comes first, through DX12 (#545); the integrated GPU stays the fallback a restart reaches. + #[test] + fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's, as before. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); + } + + /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the + /// discrete one. Power saving and the display agree, in either enumeration order. + #[test] + fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); + } + + /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so + /// its GPU draws, whichever kind it is. + #[test] + fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); + } + + /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, + /// Metal's zeros) change nothing: power saving's order, the old behaviour. + #[test] + fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); + } + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); + // The #502 machine's adapters report no ids in these tests: the primary display never matches. + let c = issue_502(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); + } + + /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. + #[test] + fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); + } + + /// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic + /// looks at the displays. + #[test] + fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None, None)); + assert!(automatic(Some("automatic"), None)); + assert!(automatic(Some("powerSaving"), None)); + assert!(!automatic(Some("lowPower"), None)); + assert!(!automatic(Some("highPerformance"), None)); + assert!(!automatic(None, Some(PowerPreference::None))); + assert!(!automatic(Some("automatic"), Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some("lowPower"), None).is_empty()); + assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None, None), display_gpus()); + } + + #[test] + fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids(""), None); + } + + /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary + /// display, and none is listed twice. (A headless CI runner may list none.) + #[cfg(windows)] + #[test] + fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } + } + + #[test] + fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } + } + + /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port + /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without + /// PCI ids. Each GPU is listed once, the one with the panel as primary. + #[test] + fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) + } + + #[test] + fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); + } + /// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software /// renderer, and OpenGL. @@ -270,7 +651,7 @@ mod tests { #[test] fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { let c = issue_502(); - let order = adapter_order(&c, power_preference(None, None), None, &[]); + let order = adapter_order(&c, power_preference(None, None), &[], None, &[]); let first = &c[order[0]].name; if cfg!(any(windows, target_os = "macos")) { assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); @@ -283,7 +664,7 @@ mod tests { #[test] fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { let c = issue_502(); - let order = |power| names(&c, &adapter_order(&c, power, None, &[])); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); assert_eq!( order(PowerPreference::None), [ @@ -324,14 +705,15 @@ mod tests { name: name.into(), backend: Backend::Metal, device_type, + pci: (0, 0), presents: true, }; let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, None, &[]); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, None, &skip)), ["NVIDIA GeForce GT 750M"]); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); // A comma in a name can't split the list. assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); } @@ -341,7 +723,7 @@ mod tests { let c = issue_502(); let mut skip = vec![]; let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, None, &skip).first() { + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { tried.push(c[i].name.clone()); skip.push(c[i].key.clone()); } @@ -362,33 +744,36 @@ mod tests { c[0].presents = false; c[3].presents = false; for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, None, &[]); + let order = adapter_order(&c, power, &[], None, &[]); assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); assert_eq!(order.len(), 2); } for p in &mut c { p.presents = false; } - assert!(adapter_order(&c, PowerPreference::None, None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, None, &[]).is_empty()); + assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); } #[test] fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { let c = issue_502(); - let order = adapter_order(&c, PowerPreference::HighPerformance, Some("radv"), &[]); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); assert_eq!(order.len(), 4); // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, Some("GeForce"), &[]); + let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); // An empty name or one nothing matches changes nothing. for named in ["", "no such gpu"] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, None, &[])); + assert_eq!( + adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), + adapter_order(&c, PowerPreference::LowPower, &[], None, &[]) + ); } // A named adapter that failed is left out like any other. let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, Some("radv"), &skip)[0], 0); + assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); } /// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the @@ -402,9 +787,9 @@ mod tests { gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), ]; - let order = adapter_order(&c, PowerPreference::LowPower, None, &[]); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); } /// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. @@ -419,9 +804,9 @@ mod tests { gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), ]; for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}"); + assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); } #[test] diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 963fe6c2..c90bad58 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -431,7 +431,21 @@ fn main() -> std::process::ExitCode { #[cfg(feature = "wgpu")] let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str); #[cfg(feature = "wgpu")] - let power = gpu::power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env()); + let power_env = eframe::wgpu::PowerPreference::from_env(); + #[cfg(feature = "wgpu")] + let power = gpu::power_preference(gpu_pref, power_env); + // Automatic draws on the GPU that drives the (primary) display: a GPU without a monitor reset + // its driver and took every monitor down (pdfcraft#378). Logged first: the first question in + // every black-window report. + #[cfg(feature = "wgpu")] + let displays = gpu::preferred_displays(gpu_pref, power_env); + #[cfg(feature = "wgpu")] + if gpu::automatic(gpu_pref, power_env) { + let listed: Vec = displays.iter().map(ToString::to_string).collect(); + log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") }); + } else { + log::info!("graphics processor chosen by the user ({power:?}): which GPU drives the display isn't considered"); + } #[cfg(feature = "wgpu")] let startup = std::sync::Arc::new(gpu::Startup::default()); let options = eframe::NativeOptions { @@ -458,7 +472,7 @@ fn main() -> std::process::ExitCode { options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY; // Only adapters that can show the window, in the order `gpu` gives (#306, #502). if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - create.native_adapter_selector = Some(gpu::selector(power, startup.clone())); + create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone())); // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only // costs a compute shader at start-up, one some drivers can't compile (OCLP-patched // Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on. diff --git a/docs/development.md b/docs/development.md index c7424e4d..9b146825 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,10 +45,15 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses. -- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference; then, on Windows, where each GPU is listed under both, a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. `WGPU_BACKEND=vulkan` still picks Vulkan. -- **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart. - - **Automatic on Windows and macOS** is power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306). - - **Automatic on Linux and the BSDs** keeps the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502). +- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then, on Windows, where each GPU is listed under both, a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. `WGPU_BACKEND=vulkan` still picks Vulkan. +- **The GPU that drives the display.** A GPU without a monitor is the wrong one even when it says it can show the window: on a desktop with a Ryzen's integrated GPU (no monitor) and an NVIDIA card driving both monitors, power saving picked the integrated GPU, Windows had to present every frame across adapters, and the integrated GPU's driver reset under that: the graphics device was lost, or the desktop's compositor went down and every monitor stayed black for minutes (pdfcraft#378). So Automatic asks the system which GPU drives which display and puts that GPU first; the power preference only orders the rest, and decides alone where the system can't say. + - **Windows:** `EnumDisplayDevices` (the display devices attached to the desktop, and the primary one), through the `winsafe` crate; the PCI ids in each device id are matched against wgpu's adapters. The registry doesn't tell a user which adapter drives a display, and WMI reports a resolution for a GPU without a monitor, so neither is used. + - **Linux:** the connected connectors in `/sys/class/drm` and their cards' PCI ids; a built-in panel (`eDP`, `LVDS`, `DSI`) counts as the primary display. + - **macOS**, OpenGL adapters (no device id) and Metal (no ids at all): no match, so the power preference decides as before. + - The log's first lines list the display GPUs (`display GPUs (PCI vendor:device): 10de:2204 (primary)`), every adapter with whether it drives a display, and the one drawn on and why (`drawing on NVIDIA GeForce RTX 3090 (Dx12, DiscreteGpu): it drives the primary display`). That is the first thing to look for in a black-window report. +- **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart. Power Saving and High Performance are the user's own choice and order by kind alone, without looking at the displays; so does `WGPU_POWER_PREF`. + - **Automatic on Windows and macOS** is the GPU that drives the primary display, then power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306). On a hybrid laptop the integrated GPU drives the panel, so both rules agree. + - **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502). - 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`. - **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking. - **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference: From 2c1a1526df405a13c79c189a808efd7c4ec04386 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:27:19 +0700 Subject: [PATCH 28/43] Fix clippy on main: Create Outlines passes the text, not a reference to it needless_borrow from #850, which a stale lint result let through. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/typecmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4d985a44..2395b3f5 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -252,7 +252,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { children.push(Arc::new(node)); } // Underline and strikethrough bars become paths of their run's paint too (#847). - for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), &t) { + for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) { let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None); node.appearance = st.appearance(); From c599522061ca64e51fe7a93db87140a698c14fde Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 18:52:54 +0700 Subject: [PATCH 29/43] Fix #735: Illustrator EPS and .ai files match their page in Fit to Artwork Bounds and the page check - Fit to Artwork Bounds fits the art exports draw: hidden objects and layers, guides and template layers don't count, and the bounds stop at a layer's clip. - The comparison with the page catches an object one draws that the other doesn't (pixels whose neighbours all differ, away from type), a light colour against white, and, for an EPS whose page is its art's box, art the layers print outside it. - textAs: outlines opens a .ai whose type shows from its PDF part, which has the outlines. - document.open takes editingData: false to open an Illustrator EPS or .ai as its page or PDF part only, for print pipelines. Part of #637. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/fileio/load.rs | 13 +- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/pdfimport.rs | 14 +- .../engine/src/cmd/fileio/tests_aiimport.rs | 49 ++++ crates/engine/src/cmd/layer.rs | 14 +- crates/engine/tests/import_fuzz.rs | 8 +- crates/eps/src/import/mod.rs | 12 +- crates/eps/src/import/native.rs | 222 +++++++++++++++--- crates/eps/src/import/tests_native.rs | 80 ++++++- crates/eps/src/lib.rs | 2 +- crates/render/src/encode/mod.rs | 4 +- crates/render/src/encode/tests.rs | 32 +++ crates/render/src/lib.rs | 2 +- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + docs/mcp.md | 14 +- 21 files changed, 413 insertions(+), 65 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 77ad12b4..c832adf9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -252,7 +252,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form command, gesture and dialog drivable by agents (MCP, CLI, control channel). ## Out of scope (by design or by law) -- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type made from the text document and other type left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type made from the text document and other type left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. - **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in. - **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original. diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index c0c80fe6..080d2434 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -137,11 +137,12 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// layers and hidden objects as they were, and the art outside its artboard (its PDF part has /// only what is on the artboards), as an EPS of the editor is read. Anything else as it was. fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec) -> (Document, Vec) { - if opts.pages.is_some() || !opts.layers { + if opts.pages.is_some() || !opts.layers || !opts.editing_data { return (doc, warnings); } let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) }; - let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings); + let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines; + let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined); // The note that the art outside the artboard is lost is the reason the layers weren't read. if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) { return (doc, warnings); @@ -151,10 +152,10 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning } /// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's -/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers are -/// off), an error when it can't be read. +/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers or the +/// editing data are off), an error when it can't be read. fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option)>> { - if opts.pages.is_some() || !opts.layers { + if opts.pages.is_some() || !opts.layers || !opts.editing_data { return None; } let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?; @@ -178,7 +179,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => { let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { - let r = vectorcraft_eps::import(bytes).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index f124726f..4dcdf976 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS or .ai that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 3df3ade8..32de950b 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -32,6 +32,10 @@ pub struct LoadOptions { pub layers: bool, /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). pub dxf: vectorcraft_cad::ImportOptions, + /// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its + /// layers, hidden objects and the art outside its artboards); else only what its page or PDF + /// part prints comes in. + pub editing_data: bool, } impl Default for LoadOptions { @@ -45,6 +49,7 @@ impl Default for LoadOptions { text_as: TextAs::default(), layers: true, dxf: Default::default(), + editing_data: true, } } } @@ -52,7 +57,8 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options). + /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false) and `dxf` + /// (the DXF import options). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -75,8 +81,12 @@ impl LoadOptions { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, }; + let editing_data = match p.get("editingData").filter(|v| !v.is_null()) { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true or false"))?, + }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf }) + Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index ae3acc5e..01ec2a92 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -108,3 +108,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() { let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string(); assert!(e.contains("placeholder"), "{e}"); } + +#[test] +fn editing_data_false_opens_only_what_the_page_prints() { + let mut s = Session::new(); + for (name, file) in [("art.eps", ai::eps(&ai::sample_data(), ai::page_ps())), ("art.ai", ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()))] + { + let r = open(&mut s, name, &file, json!({"editingData": false})).unwrap(); + let layers = &s.doc().unwrap().doc.layers; + assert_eq!(layers.len(), 1, "{name}: {r}"); + assert_ne!(names(layers), ["Art"], "{name}: the page's layer"); + } + let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string(); + assert!(e.contains("editingData must be true or false"), "{e}"); + // A file that is only its editing data can't open without it. + let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string(); + assert!(e.contains("placeholder"), "{e}"); +} + +#[test] +fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() { + // A shown square; a hidden one and a long guide far from it, which the page doesn't draw. + let art = + format!("0 g\n{}f\n1 Xw\n{}f\n0 Xw\n-500 20 m\n700 20 L\n(N) *\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(150.0, 60.0, 190.0, 95.0)); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art)); + let mut s = Session::new(); + let r = open(&mut s, "fit.eps", &ai::eps(data.as_bytes(), "0 setgray 10 10 40 40 rectfill\n"), json!({})).unwrap(); + let layer = &s.doc().unwrap().doc.layers[0]; + let kids = layer.children().unwrap(); + assert_eq!(kids.len(), 3, "{r}"); + assert!(!kids[1].visible && matches!(kids[2].kind, NodeKind::Path { guide: true, .. })); + s.execute("artboard.fitToArt", &json!({})).unwrap(); + // The square, 50 to 90 down the 100-point page. + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 50.0, 50.0, 90.0)); +} + +#[test] +fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part() { + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n"; + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{text}", ai::rect(10.0, 10.0, 50.0, 50.0)))); + let file = ai::ai(&compressed(data.as_bytes()), "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET"); + let mut s = Session::new(); + // As type, the page's type goes into the text object of the layers. + let r = open(&mut s, "type.ai", &file, json!({})).unwrap(); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}"); + // As outlines, only the PDF part has them. + let r = open(&mut s, "type.ai", &file, json!({"textAs": "outlines"})).unwrap(); + assert!(r["warnings"].to_string().contains("outlines"), "{r}"); + assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}"); +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index bc49386a..2bae10ab 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -139,7 +139,15 @@ pub fn specs() -> Vec { has_doc, artboard_set ), - cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art), + cmd!( + "artboard.fitToArt", + "Fit to Artwork Bounds", + ["Object", "Artboards"], + None, + "{index?} the artboard fits the art that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count)", + has_doc, + artboard_fit_art + ), cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel), cmd!( "layer.clippingMask.toggle", @@ -810,9 +818,11 @@ fn rect_map(from: Rect, to: Rect) -> Option { xf.is_finite().then_some(xf) } +/// The artboard fits the art that exports draw: hidden objects and layers, guides and template +/// layers don't count. fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?; + let b = vectorcraft_render::encode::art_bounds(&s.doc()?.doc).ok_or_else(|| EngineError::Other("no artwork".into()))?; s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()})) } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d7e2ef40..f50ac1fa 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1731,7 +1731,7 @@ proptest! { *x = b; } } - survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?; + survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false).0))?; } } @@ -1886,9 +1886,13 @@ proptest! { fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) { use vectorcraft_testkit::ai; let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" "))); - survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![]).0))?; + survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false).0))?; + survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true).0))?; let eps = ai::eps(data.as_bytes(), ai::page_ps()); survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?; + // An EPS whose page is its art's box, not its artboard: what prints outside it is compared too. + let boxed = ai::eps(data.replace("%AI3_Cropmarks: 0 0 200 100", "%AI3_Cropmarks: -50 -50 300 200").as_bytes(), ai::page_ps()); + survive("hostile editing data in an EPS of its art's box", || vectorcraft_engine::cmd::fileio::load("x.eps", &boxed).ok().map(|l| l.doc))?; } /// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files. diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 9ce6e72b..8dac8a78 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -166,8 +166,15 @@ fn reason(e: &PsError, fault: Option<&Fault>) -> String { } } -/// Read an EPS or PostScript file (its first page). +/// Read an EPS or PostScript file (its first page), an Illustrator EPS through the editing copy of +/// its art when it carries one (see `native`). pub fn import(bytes: &[u8]) -> Result { + import_with(bytes, true) +} + +/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data` +/// (else what its page prints, as any EPS). +pub fn import_with(bytes: &[u8], editing_data: bool) -> Result { let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?; if !ps.starts_with(b"%!") { return Err("this is not a PostScript file".into()); @@ -189,7 +196,8 @@ pub fn import(bytes: &[u8]) -> Result { } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) => return Ok(native::layered(ps, finish(out))), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out))), + (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), }; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 6c4e2bf5..26febde8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -15,7 +15,9 @@ //! //! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did //! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is -//! also the safety net: when the layers' art looks different from it, the page is used. +//! also the safety net: when the layers' art looks different from it (one draws an object the +//! other doesn't, or much of it differs; an EPS whose page is its art's box has nothing printed +//! outside it), the page is used. //! //! Where a file doesn't match what this module expects, it is left alone and its page is imported. @@ -550,10 +552,49 @@ fn as_printed(doc: &Document) -> Option { hide(&mut d.layers).then_some(d) } -/// The lightness (0 to 255, Rec. 601 weights) of a premultiplied pixel seen on white. -fn lightness(p: &[u8; 4]) -> u8 { - let on_white = |c: u8| u32::from(c) + 255 - u32::from(p[3]).min(255); - ((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8 +/// A premultiplied pixel seen on white. +fn on_white(p: &[u8; 4]) -> [u32; 3] { + let paper = 255 - u32::from(p[3]).min(255); + [0, 1, 2].map(|i| (u32::from(p[i]) + paper).min(255)) +} + +/// The lightness (0 to 255, Rec. 601 weights) of a pixel seen on white. +fn lightness([r, g, b]: [u32; 3]) -> u32 { + (r * 299 + g * 587 + b * 114) / 1000 +} + +/// How far (0 to 255) a pixel seen on white is from white: how much ink it has. +fn ink([r, g, b]: [u32; 3]) -> u32 { + 255 - r.min(g).min(b) +} + +/// How far apart two pixels' lightness, or their ink where one is white, may be for them to look +/// alike. +const SAME_PIXEL: u32 = 48; +/// The most ink a pixel seen as white has. +const WHITE: u32 = 8; + +/// Do two pixels look different: in lightness, or one inked (as a light yellow is) where the other +/// is white? +fn differ(p: &[u8; 4], q: &[u8; 4]) -> bool { + let (p, q) = (on_white(p), on_white(q)); + let (a, b) = (ink(p), ink(q)); + lightness(p).abs_diff(lightness(q)) > SAME_PIXEL || (a.min(b) <= WHITE && a.abs_diff(b) > SAME_PIXEL) +} + +/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of +/// those whose neighbours all differ too, away from type (where one draws an object that the other +/// doesn't, not the edges of one drawn a little differently, or type laid out a little +/// differently: a line the page sets in pieces is laid out afresh as one). +#[derive(Clone, Copy)] +struct Difference { + any: f64, + solid: f64, +} + +/// The scale the comparisons draw a region at: about 400 pixels along its longer side. +fn compare_scale(r: Rect) -> f64 { + (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) } /// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers @@ -561,6 +602,8 @@ fn lightness(p: &[u8; 4]) -> u8 { /// can share their keys and not their pixels, so the two don't share one). struct PageView { page: vectorcraft_render::Rendered, + /// Where the page's own type is, in its pixels. + page_type: Vec, layers: vectorcraft_render::Renderer, scale: f64, } @@ -568,36 +611,118 @@ struct PageView { impl PageView { fn new(frame: &Frame<'_>) -> Self { let ra = frame.page_rect; - let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0); + let scale = compare_scale(ra); // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in // the app that wrote the file. let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false); - Self { page, layers: vectorcraft_render::Renderer::new(), scale } + let mut page_type = vec![]; + type_areas(&frame.page.layers, ra, scale, &mut page_type); + Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale } } - /// How much `b` on `rb` (a rectangle the page's size) draws differently from the page (0 to 1), - /// each seen on white. Pixels are compared by lightness: the page of an RGB document is written in - /// the colours' CMYK equivalents, and what the comparison looks for is art that is missing or out - /// of place. - fn difference(&mut self, b: &Document, rb: Rect) -> f64 { + /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). + fn difference(&mut self, b: &Document, rb: Rect) -> Difference { // The page doesn't print what isn't printed. let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let (x, y) = (&self.page, self.layers.render_region(b, rb, self.scale, false)); - if x.width != y.width || x.height != y.height { - return 1.0; - } - let differing = - x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count(); - differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0) + let y = self.layers.render_region(b, rb, self.scale, false); + let mut areas = self.page_type.clone(); + type_areas(&b.layers, rb, self.scale, &mut areas); + compared(&self.page, &y, &areas) } } +/// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), +/// for a region other than the page's ([`PageView`] draws that one once). +fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let scale = compare_scale(ra); + // A renderer each: the two documents' images can share their keys and not their pixels. + let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false); + let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false); + let mut areas = vec![]; + type_areas(&a.layers, ra, scale, &mut areas); + type_areas(&b.layers, rb, scale, &mut areas); + compared(&x, &y, &areas) +} + +/// How two drawings of the same size differ, each drawn on nothing and seen on white, away from +/// the type in `areas` (pixels). Pixels are compared by lightness, and by whether they are inked +/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, +/// and what the comparison looks for is art that is missing or out of place. +fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference { + if x.width != y.width || x.height != y.height { + return Difference { any: 1.0, solid: 1.0 }; + } + let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect(); + let (w, h) = (x.width as usize, x.height as usize); + let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); + let typed = covered(areas, w, h); + // A pixel on the region's edge has neighbours outside it, which don't count as differing. + let solid = (1..h.saturating_sub(1)) + .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) + .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) + .count(); + let area = (w as f64 * h as f64).max(1.0); + Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, solid: solid as f64 / area } +} + +/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin +/// for the strokes round it). +fn type_areas(nodes: &[Arc], region: Rect, scale: f64, out: &mut Vec) { + for n in nodes.iter().filter(|n| n.visible) { + if let NodeKind::Text(_) = n.kind + && let Some(b) = n.visual_bounds() + { + let px = + |r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale); + out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH))); + } else if let Some(c) = n.children() { + type_areas(c, region, scale, out); + } + } +} + +/// Which of `w` × `h` pixels (row by row) lie in one of `areas`. +fn covered(areas: &[Rect], w: usize, h: usize) -> Vec { + // Each area adds one inside it: corners marked, then summed along the rows and down the columns. + let stride = w + 1; + let mut count = vec![0i64; stride * (h + 1)]; + let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 }; + for r in areas { + let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h)); + for (x, y, d) in [(x0, y0, 1), (x1, y0, -1), (x0, y1, -1), (x1, y1, 1)] { + if let Some(c) = count.get_mut(y * stride + x) { + *c += d; + } + } + } + for row in count.chunks_mut(stride) { + let mut run = 0; + for c in row { + run += *c; + *c = run; + } + } + let mut column = vec![0i64; stride]; + for row in count.chunks_mut(stride) { + for (c, sum) in row.iter_mut().zip(column.iter_mut()) { + *sum += *c; + *c = *sum; + } + } + (0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect() +} + /// More of the page than this differing between the layers' art and the page's own, and the /// layers aren't used. const MAX_DIFFERENCE: f64 = 0.05; /// More than this and the import says so. const NOTABLE_DIFFERENCE: f64 = 0.002; +/// More of the page than this where one draws an object that the other doesn't (see +/// [`Difference`]), and the layers aren't used: about a centimetre square on a letter page. +const MAX_SOLID_DIFFERENCE: f64 = 0.001; /// Which box of the editing copy is the page. #[derive(Clone, Copy)] @@ -616,13 +741,17 @@ struct Frame<'a> { page_rect: Rect, /// The same area in the layers' document. rect: Rect, + /// The page holds all the art that prints (an EPS's page is its art's box, unless it was + /// saved as its artboard): the layers print nothing outside it. + whole: bool, } /// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the /// file read as its page, put in its text objects → the document and its notes. Without a page /// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and -/// nothing is compared. -fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Document, Vec), String> { +/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in +/// the text objects. +fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) -> Result<(Document, Vec), String> { let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?; let (mut shown, mut hidden) = (vec![], vec![]); let mut frame = None; @@ -648,10 +777,18 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Documen Arc::make_mut(n).transform(shift, false); } } - frame = Some(Frame { page: visible, page_rect, rect }); + // An EPS saved as its artboard has the art outside it left out of its page. + let artboard_page = + artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); + let whole = matches!(page, Page::Art) && !artboard_page; + frame = Some(Frame { page: visible, page_rect, rect, whole }); } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); + // Type asked for as outlines is the page's: the text objects that show can't be filled. + if outlined && infos.iter().any(|i| i.shown) { + return Err("its type is to come in as outlines, which only its page has".into()); + } if infos.iter().all(|i| !i.shown) && !shown.is_empty() { return Err("its page has text, and its layers have no text objects to put it in".into()); } @@ -753,7 +890,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u if found.is_empty() || found.len() > MAX_WEIGHED { return 0; } - let mut with = view.difference(doc, rect); + let mut with = view.difference(doc, rect).any; let mut removed = 0; for (id, bounds) in found { if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) { @@ -763,7 +900,7 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u if !take_out(&mut without.layers, id, true) { continue; } - let d = view.difference(&without, rect); + let d = view.difference(&without, rect).any; if d - with <= UNSEEN_MARGIN { take_out(&mut doc.layers, id, false); with = d; @@ -773,17 +910,30 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u removed } -/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note -/// to give when they differ a little. +/// How far (points) the art the layers print may reach past a page that holds it all. +const PAST_THE_PAGE: f64 = 1.0; + +/// Do the layers' art and the page's own look alike (where the page holds all the art that prints, +/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little. fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result, String> { - let diff = view.difference(layered, frame.rect); - if diff > MAX_DIFFERENCE { - return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0)); + let mut diff = view.difference(layered, frame.rect); + if frame.whole + && let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered)) + .filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a)) + { + // The page and the art the layers print beyond it, each in its own document's place. + let all = frame.rect.union(art); + let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0); + let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all); + diff = Difference { any: diff.any.max(round.any), solid: diff.solid.max(round.solid) }; } - Ok((diff > NOTABLE_DIFFERENCE).then(|| { + if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE { + return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0)); + } + Ok((diff.any > NOTABLE_DIFFERENCE).then(|| { format!( "the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)", - diff * 100.0 + diff.any * 100.0 ) })) } @@ -794,7 +944,7 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result /// has one that can't be used, `visible` with a warning that says why. pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { let Some(data) = ai::eps_data(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art)); + let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false)); match result { Ok((document, mut warnings)) => { for w in &visible.warnings { @@ -816,12 +966,14 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { /// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the /// file, its artboards, and the art that lies outside them, which its PDF part doesn't have. /// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'. -pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec) -> (Document, Vec) { +/// `outlined`: the PDF part's type was read as outlines (a file whose type shows then comes in as +/// its PDF part). +pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool) -> (Document, Vec) { let Ok(data) = ai::decode_private(private) else { return (visible, warnings) }; if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") { return (visible, warnings); } - match build(&data, Some(&visible), Page::Artboard) { + match build(&data, Some(&visible), Page::Artboard, outlined) { Ok((done, notes)) => { let mut all = warnings; all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); @@ -839,7 +991,7 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec) -> ( /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard) + build(&data, None, Page::Artboard, false) } #[cfg(test)] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 1483b5cf..2140553f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -215,7 +215,7 @@ fn pdf_part(x: f64, y: f64) -> Document { fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { // A square on the artboard, and one above it (the artboard is y 0 to 100 here). let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60)))); - let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()]); + let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false); assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}"); assert_eq!(warnings, ["a note"], "nothing else to say"); let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap(); @@ -227,12 +227,12 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() { let pdf = pdf_part(10.0, 86.0); // No editing data this reads. - let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![]); + let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false); assert_eq!(d.layers.len(), pdf.layers.len()); assert!(w.is_empty()); // Editing data that isn't for this page. let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50"); - let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![]); + let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false); assert_eq!(names(&d), names(&pdf)); assert!(w.iter().any(|w| w.contains("different page")), "{w:?}"); } @@ -280,7 +280,7 @@ fn custom_colours_open_paths_and_planar_group_marks_are_read() { let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\ 0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF"; let r = import(&older_eps( - "0 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill", + "0.1 0.2 0.3 0.4 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill 0.8 0.1 0 0 setcmykcolor 20 20 moveto 24 20 lineto 24 24 lineto closepath fill", &native(&layer("Art", true, body)), )) .unwrap(); @@ -399,9 +399,9 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document fn a_text_object_written_as_comments_is_read_like_any_other() { let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); - let art = native_with_text(&layer("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &commented)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); - let texts = text_of(&r.document.layers[0]); + let texts = text_of(&r.document.layers[1]); assert_eq!(texts.len(), 1, "{:?}", r.warnings); assert_eq!(texts[0].plain_text(), "Hi"); } @@ -453,9 +453,9 @@ fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_objec let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]); let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() }; let body = text_object(0) + &commented(0) + &commented(1); - let art = native_with_text(&layer("Spare", false, &body), &doc); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &body)), &doc); let r = import(&eps(&page_square(10, 10), &art)).unwrap(); - let texts: Vec = text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect(); + let texts: Vec = text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect(); assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings); } @@ -542,3 +542,67 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() { assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}"); assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err()); } + +#[test] +fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() { + // A 20-point square that the page doesn't draw: 4% of the page, and an object all the same. + let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf"; + let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings); +} + +/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing +/// `page`, with the editing copy of `layers`. +fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec { + let [x0, y0, x1, y1] = art_box; + let native = native(layers).replace( + "%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100", + &format!("%%BoundingBox: {x0} {y0} {x1} {y1}\n%%HiResBoundingBox: {x0} {y0} {x1} {y1}"), + ); + String::from_utf8(eps(page, &native)) + .unwrap() + .replacen("%%BoundingBox: 0 0 100 100", &format!("%%BoundingBox: {x0} {y0} {x1} {y1}"), 1) + .into_bytes() +} + +#[test] +fn art_the_layers_print_outside_an_eps_page_of_its_art_makes_it_come_in_as_its_page() { + // The page is the box of the art it prints, round the square at (10, 10). + let file = |far: &str| eps_of_art([10, 10, 14, 14], &page_square(10, 10), &(layer("Art", true, &square(10, 10)) + far)); + // A square the layers print far from it: the page would have it. + let r = import(&file(&layer("Far", true, &square(80, 80)))).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings); + // Hidden, or on a layer that doesn't print, the page doesn't have it. + let r = import(&file(&layer("Far", false, &square(80, 80)))).unwrap(); + assert_eq!(names(&r.document), ["Art", "Far"], "{:?}", r.warnings); + let not_printed = format!("%AI5_BeginLayer\n1 1 1 0 0 0 1 0 79 128 255 0 50 0 Lb\n(Notes) Ln\n{}\nLB\n%AI5_EndLayer--\n", square(80, 80)); + let r = import(&file(¬_printed)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Notes"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn an_eps_saved_as_its_artboard_keeps_the_art_outside_it() { + // The page is the artboard (0 0 100 100): the art beyond it is the pasteboard's. + let art = native(&(layer("Art", true, &square(10, 10)) + &layer("Scraps", true, &square(120, 10)))); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Scraps"], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +#[test] +fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() { + let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + // Type that shows: the PDF part's outlines are its type, which the text objects can't hold. + let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc); + let (d, w) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true); + assert_eq!(names(&d), ["Layer 1"], "{w:?}"); + assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}"); + // Type only on a hidden layer: the layers come in. + let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc); + let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true); + assert_eq!(names(&d), ["Art", "Notes"], "{w:?}"); +} diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 56a7ec21..7959f960 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -34,7 +34,7 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, ai_alone, family_style, import, is_loss, layered_ai}; +pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai}; pub use print::{PrintJob, PrintPage, print}; use vectorcraft_doc::Document; diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index fd8338bb..d3fd5054 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -86,7 +86,7 @@ impl RasterExportOptions { self.ppi / 72.0 } - /// What the renderer draws for `format`: template layers left out, over the background + /// What the renderer draws for `format`: template layers and guides left out, over the background /// (white for a JPEG or a flat PSD without one). pub fn render_options(&self, format: RasterFormat) -> RenderOptions { let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers); @@ -193,7 +193,7 @@ fn drawn_bounds(n: &Node) -> Option { } match &n.kind { NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(c))) } _ if fx::has_fx(n) => fx::visual_bounds(n), diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index e5192e78..43233ae1 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -189,3 +189,35 @@ fn art_bounds_cover_visible_art_only() { let empty = Document::new(10.0, 10.0); assert_eq!(art_bounds(&empty), None); } + +/// A black path round `r` in the first layer of `d`, made a guide when `guide`. +fn add_rect(d: &mut Document, r: Rect, guide: bool) { + let id = d.alloc_id(); + let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + if let NodeKind::Path { guide: g, .. } = &mut n.kind { + *g = guide; + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); +} + +#[test] +fn guides_dont_count_in_the_arts_bounds() { + let mut d = circle_doc(); + add_rect(&mut d, Rect::new(0.0, 15.0, 400.0, 15.0), true); + let b = art_bounds(&d).unwrap(); + assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6, "the guide doesn't count: {b:?}"); +} + +#[test] +fn art_bounds_end_at_a_clipping_layers_clip() { + let mut d = Document::new(40.0, 30.0); + // The layer clips its art to its first object (added last): what lies beyond isn't drawn. + add_rect(&mut d, Rect::new(-100.0, -100.0, 200.0, 200.0), false); + add_rect(&mut d, Rect::new(2.0, 3.0, 20.0, 10.0), false); + let layer = std::sync::Arc::make_mut(&mut d.layers[0]); + if let NodeKind::Layer { clip, .. } = &mut layer.kind { + *clip = true; + } + assert_eq!(art_bounds(&d), Some(Rect::new(2.0, 3.0, 20.0, 10.0))); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9a9899a7..156a3fe2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -582,7 +582,7 @@ impl Renderer { } /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, - /// as exported: template layers are left out. + /// as exported: template layers and guides are left out. pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() }; self.render_region_with(doc, region, scale, &opts) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d87027b..62677621 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3170,6 +3170,7 @@ axis Both Ambos @msg its editing data is for a different page sus datos de edición son de otra página @msg its layers use {_1}, which aren't read yet sus capas usan {_1}, que aún no se leen @msg its page has text, and its layers have no text objects to put it in su página tiene texto y sus capas no tienen objetos de texto donde ponerlo +@msg its type is to come in as outlines, which only its page has su texto debe llegar como contornos, y solo su página los tiene @msg the art on them differs from the page's in {_1}% of it el arte de las capas difiere del de la página en el {_1} % de su superficie @msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7e3b4820..4faafcb7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3167,6 +3167,7 @@ axis Both Les deux @msg its editing data is for a different page ses données de modification concernent une autre page @msg its layers use {_1}, which aren't read yet ses calques utilisent {_1}, qui ne sont pas encore lus @msg its page has text, and its layers have no text objects to put it in sa page contient du texte, et ses calques n’ont aucun objet texte où le mettre +@msg its type is to come in as outlines, which only its page has son texte doit être importé sous forme de contours vectorisés, que seule sa page contient @msg the art on them differs from the page's in {_1}% of it le dessin des calques diffère de celui de la page sur {_1} % de sa surface @msg `to` must be "corner" or "smooth" `to` doit être "corner" ou "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray Les fichiers BMP sont en RVB ou en niveaux de gris : colorModel rgb ou gray diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1c1208f8..6532854a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3170,6 +3170,7 @@ axis Both Entrambi @msg its editing data is for a different page i suoi dati di modifica sono di un'altra pagina @msg its layers use {_1}, which aren't read yet i suoi livelli usano {_1}, che non vengono ancora letti @msg its page has text, and its layers have no text objects to put it in la sua pagina ha del testo e i suoi livelli non hanno oggetti di testo in cui metterlo +@msg its type is to come in as outlines, which only its page has il suo testo deve arrivare come contorni, che ha solo la sua pagina @msg the art on them differs from the page's in {_1}% of it l'illustrazione dei livelli differisce da quella della pagina per il {_1}% della sua superficie @msg `to` must be "corner" or "smooth" `to` deve essere "corner" o "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray I file BMP sono RGB o in scala di grigi: colorModel rgb o gray diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 37310f43..fa38398c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3177,6 +3177,7 @@ theme Appearance 外観 @msg it has more than one artboard アートボードが複数あります @msg its editing data has no layers 編集データにレイヤーがありません @msg its editing data is damaged 編集データが破損しています +@msg its type is to come in as outlines, which only its page has 文字をアウトラインとして読み込む設定ですが、アウトラインはページにしかありません @msg {_1} files {_1} 個のファイル @msg A browser with WebGPU or WebGL2 is required. WebGPU または WebGL2 に対応したブラウザーが必要です。 @msg Loading VectorCraft… VectorCraft を読み込んでいます… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b8b804da..6f88ab3e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3166,6 +3166,7 @@ axis Both Оба @msg its editing data is for a different page его данные для редактирования относятся к другой странице @msg its layers use {_1}, which aren't read yet его слои используют {_1}, что пока не читается @msg its page has text, and its layers have no text objects to put it in на его странице есть текст, а на слоях нет текстовых объектов, куда его поместить +@msg its type is to come in as outlines, which only its page has его текст должен прийти в виде контуров, а они есть только на его странице @msg the art on them differs from the page's in {_1}% of it изображение на слоях отличается от изображения на странице на {_1} % площади @msg `to` must be "corner" or "smooth" `to` должно быть "corner" или "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray файлы BMP бывают RGB или в оттенках серого: colorModel rgb или gray diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e3778469..4d00da15 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3153,6 +3153,7 @@ axis Both Обидві @msg its editing data is for a different page його дані для редагування відносяться до іншої сторінки @msg its layers use {_1}, which aren't read yet його шари використовують {_1}, що поки не читається @msg its page has text, and its layers have no text objects to put it in на його сторінці є текст, а на шарах немає текстових об’єктів, куди його помістити +@msg its type is to come in as outlines, which only its page has його текст має прийти у вигляді контурів, а вони є лише на його сторінці @msg the art on them differs from the page's in {_1}% of it зображення на шарах відрізняється від зображення на сторінці на {_1} % площі @msg `to` must be "corner" or "smooth" `to` має бути "corner" або "smooth" @msg BMP files are RGB or grayscale: colorModel rgb or gray Файли BMP бувають RGB або у відтінках сірого: colorModel rgb або gray diff --git a/docs/mcp.md b/docs/mcp.md index a673e4f6..c4ee8a06 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -416,7 +416,19 @@ strokes, effects or a brush comes in as its drawn look (a group named after it). text object's place; hidden point type is made from the file's text document. A file whose editing data has symbols, pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain -groups open ungrouped), with a warning saying why. +groups open ungrouped), with a warning saying why. Layers look different when they draw an object the page doesn't (or +miss one it draws), and, for an EPS whose page is the box of its art, when they print art outside it. With +`textAs: "outlines"` a `.ai` file whose type shows opens from its PDF content, which has the type's outlines. + +The document then has what the file's page doesn't print: hidden objects and layers, layers that don't print, guides and +the art outside the artboards, as the app that wrote the file shows them. Exports and `artboard.fitToArt` leave out the +hidden art, the guides and template layers; an EPS's artboard is the file's artboard, not the page's bounding box. A +print pipeline that wants only what the page prints opens the file with `editingData: false` (default `true`): +an EPS as its page, a `.ai` file from its PDF content (a `.ai` saved without PDF compatibility then can't be opened). + +```json +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/label.eps","editingData":false}}} +``` Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) From 4f0809953d5dab0c9386cca1a33b7c04f2a45e8e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 9 Oct 2026 20:04:42 -0400 Subject: [PATCH 30/43] chore: bump github actions to fix Node 20 deprecation warnings --- .github/workflows/affinity-corpus.yml | 2 +- .github/workflows/freebsd.yml | 10 +++---- .github/workflows/packaging-lint.yml | 2 +- .github/workflows/release.yml | 40 +++++++++++++-------------- .github/workflows/windows-arm64.yml | 8 +++--- .github/workflows/windows7.yml | 6 ++-- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/affinity-corpus.yml b/.github/workflows/affinity-corpus.yml index 3a49441b..87934817 100644 --- a/.github/workflows/affinity-corpus.yml +++ b/.github/workflows/affinity-corpus.yml @@ -24,7 +24,7 @@ jobs: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_TEST_DEBUG: "0" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 7cfc1e75..9b4ebcc7 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -29,10 +29,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 300 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # The optional craft-fonts build input (Japanese fonts), so the tests that need it run here # rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md). - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -67,10 +67,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 150 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM). # Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -97,7 +97,7 @@ jobs: for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; } done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: freebsd-package path: dist/release/* diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index b7566d42..4b7021dc 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f1f4ba9..706daac6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - id: v name: Resolve version run: | @@ -71,10 +71,10 @@ jobs: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build # step). Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -102,7 +102,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-macos path: dist/release/* @@ -130,10 +130,10 @@ jobs: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build # step). Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -167,7 +167,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -191,10 +191,10 @@ jobs: VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build # step). Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -236,7 +236,7 @@ jobs: | grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync" test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -259,8 +259,8 @@ jobs: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -274,7 +274,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -292,10 +292,10 @@ jobs: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM). # Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -328,7 +328,7 @@ jobs: bash packaging/freebsd/package.sh tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \ | grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$' - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-freebsd path: dist/release/* @@ -344,10 +344,10 @@ jobs: VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build # step). Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -371,7 +371,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: release-web path: dist/release/* @@ -392,7 +392,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index c47ed761..7c8c4a23 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -36,8 +36,8 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -53,7 +53,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: vectorcraft-windows-arm64 path: dist/arm64/* @@ -66,7 +66,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: vectorcraft-windows-arm64 path: dist diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml index aeaa756c..403be166 100644 --- a/.github/workflows/windows7.yml +++ b/.github/workflows/windows7.yml @@ -22,11 +22,11 @@ jobs: runs-on: windows-2022 timeout-minutes: 90 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 with: arch: x64 - - uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: nightly-2026-10-01 components: rust-src @@ -36,7 +36,7 @@ jobs: - name: Build, check and test the portable package shell: pwsh run: ./packaging/windows/windows7.ps1 -Test - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vectorcraft-windows7-x64-portable path: dist/release/vectorcraft-windows7-x64-portable.zip From 537fbece16cd6021fc2bbe9ebe792b6885f06407 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 21:02:29 -0400 Subject: [PATCH 31/43] Bump version to 0.8.0 (from 0.7.0) for release; refresh contributors Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 52 +-- Cargo.toml | 2 +- contributors/contributors.json | 562 +++++++++++++++++++++++++++++---- 3 files changed, 534 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58dafc03..48f0a44a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4320,7 +4320,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "block2 0.6.2", @@ -4349,7 +4349,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.7.0" +version = "0.8.0" dependencies = [ "crc32fast", "flate2", @@ -4360,7 +4360,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4372,7 +4372,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4388,7 +4388,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "log", "objc2-core-foundation", @@ -4409,7 +4409,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4421,7 +4421,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "jpeg-encoder", @@ -4438,7 +4438,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo 0.13.1", "serde_json", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "crc32fast", "flate2", @@ -4487,7 +4487,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "image", @@ -4506,7 +4506,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "proptest", @@ -4520,7 +4520,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4532,7 +4532,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "log", @@ -4551,7 +4551,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4565,7 +4565,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4577,7 +4577,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.7.0" +version = "0.8.0" dependencies = [ "aes", "flate2", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -4620,7 +4620,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "gif", @@ -4645,7 +4645,7 @@ dependencies = [ [[package]] name = "vectorcraft-svg" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "image", @@ -4669,7 +4669,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4686,7 +4686,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.7.0" +version = "0.8.0" dependencies = [ "harfrust 0.13.3", "kurbo 0.13.1", @@ -4704,7 +4704,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4719,7 +4719,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.7.0" +version = "0.8.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4732,7 +4732,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "egui", "egui_extras", @@ -4761,7 +4761,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -5871,7 +5871,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index f604adf5..00e0ddfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/contributors/contributors.json b/contributors/contributors.json index 84edd44e..2784b1ed 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,49 +2,49 @@ "schema": 1, "repo": "storytold/vectorcraft", "branch": "main", - "head": "fe9f3afcb7d32032e8f332b0594692a03e275b84", - "head_date": "2026-10-08T00:36:16Z", - "commits": 607, + "head": "866ea5878a7f6db7dd3d6b1287f9a9bb3a241c33", + "head_date": "2026-10-10T00:04:42Z", + "commits": 906, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 15, - "commits": 119, - "lines_added": 172276, - "lines_deleted": 40329, + "prs": 18, + "commits": 124, + "lines_added": 173772, + "lines_deleted": 40385, "binary_added": 37, "binary_deleted": 2, "first_commit": "2026-09-30T09:12:46Z", - "last_commit": "2026-10-07T22:04:30Z" + "last_commit": "2026-10-08T14:01:10Z" }, { "login": "bflatastic", "people_entry": false, "github_name": null, - "prs": 176, - "commits": 394, - "lines_added": 212929, - "lines_deleted": 24160, + "prs": 328, + "commits": 561, + "lines_added": 253056, + "lines_deleted": 31036, "binary_added": 2, "binary_deleted": 0, "first_commit": "2026-10-02T15:22:29Z", - "last_commit": "2026-10-08T00:36:16Z" + "last_commit": "2026-10-09T23:27:19Z" }, { "login": "storybored379", "people_entry": false, "github_name": null, - "prs": 7, - "commits": 7, - "lines_added": 456, - "lines_deleted": 49, + "prs": 8, + "commits": 9, + "lines_added": 812, + "lines_deleted": 77, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-04T06:45:57Z", - "last_commit": "2026-10-04T08:14:20Z" + "last_commit": "2026-10-09T04:35:07Z" }, { "login": "theironchef", @@ -102,14 +102,14 @@ "login": "imachus", "people_entry": false, "github_name": null, - "prs": 12, - "commits": 12, - "lines_added": 6302, - "lines_deleted": 212, + "prs": 21, + "commits": 22, + "lines_added": 7242, + "lines_deleted": 320, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-06T10:06:18Z", - "last_commit": "2026-10-07T22:20:10Z" + "last_commit": "2026-10-09T08:36:30Z" }, { "login": "petrleocompel", @@ -128,14 +128,14 @@ "login": "fahrim", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 6, - "lines_added": 1964, - "lines_deleted": 76, + "prs": 4, + "commits": 7, + "lines_added": 2134, + "lines_deleted": 83, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-06T12:48:28Z", - "last_commit": "2026-10-07T23:36:17Z" + "last_commit": "2026-10-08T01:33:25Z" }, { "login": "EthanSuquet", @@ -258,14 +258,14 @@ "login": "Haven-King", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 2, - "lines_added": 467, - "lines_deleted": 76, + "prs": 9, + "commits": 19, + "lines_added": 8627, + "lines_deleted": 720, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T18:10:39Z", - "last_commit": "2026-10-07T18:40:49Z" + "last_commit": "2026-10-09T03:25:56Z" }, { "login": "njmueller", @@ -297,14 +297,430 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 76, - "lines_deleted": 21, + "prs": 3, + "commits": 3, + "lines_added": 392, + "lines_deleted": 44, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T23:09:37Z", - "last_commit": "2026-10-07T23:09:37Z" + "last_commit": "2026-10-09T17:33:23Z" + }, + { + "login": "raytheengineer", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 94, + "lines_deleted": 12, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T03:19:20Z", + "last_commit": "2026-10-08T03:19:20Z" + }, + { + "login": "bobtherobot", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 6, + "lines_added": 824, + "lines_deleted": 93, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T03:25:09Z", + "last_commit": "2026-10-08T16:57:16Z" + }, + { + "login": "devCangrejo", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 859, + "lines_deleted": 13, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T06:30:51Z", + "last_commit": "2026-10-08T06:30:51Z" + }, + { + "login": "wstock", + "people_entry": false, + "github_name": null, + "prs": 7, + "commits": 7, + "lines_added": 749, + "lines_deleted": 92, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T07:45:45Z", + "last_commit": "2026-10-08T11:46:16Z" + }, + { + "login": "switch87", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 3, + "lines_added": 638, + "lines_deleted": 3, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T09:25:54Z", + "last_commit": "2026-10-08T09:42:11Z" + }, + { + "login": "h-a-n-a-s-h-i", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 2, + "lines_added": 52, + "lines_deleted": 52, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:13:01Z", + "last_commit": "2026-10-08T14:01:57Z" + }, + { + "login": "XusBadia", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 74, + "lines_deleted": 1, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:58:51Z", + "last_commit": "2026-10-08T12:58:51Z" + }, + { + "login": "omellado-gh", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 1, + "lines_added": 335, + "lines_deleted": 7, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:05:09Z", + "last_commit": "2026-10-08T13:05:09Z" + }, + { + "login": "callmeBalloch", + "people_entry": true, + "github_name": null, + "prs": 5, + "commits": 13, + "lines_added": 11741, + "lines_deleted": 698, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T14:01:08Z", + "last_commit": "2026-10-09T13:45:55Z" + }, + { + "login": "jeremyfry", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 57, + "lines_deleted": 2, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T14:57:01Z", + "last_commit": "2026-10-08T14:57:01Z" + }, + { + "login": "dbambus", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 72, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T15:38:18Z", + "last_commit": "2026-10-08T15:38:18Z" + }, + { + "login": "mo0kid", + "people_entry": true, + "github_name": "David John Whiting", + "prs": 2, + "commits": 6, + "lines_added": 1973, + "lines_deleted": 144, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T19:50:54Z", + "last_commit": "2026-10-08T22:03:34Z" + }, + { + "login": "MaksPV", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 3682, + "lines_deleted": 10, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T20:44:45Z", + "last_commit": "2026-10-08T20:44:45Z" + }, + { + "login": "omd0", + "people_entry": true, + "github_name": "Emad Fussi", + "prs": 1, + "commits": 1, + "lines_added": 291, + "lines_deleted": 25, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T21:34:00Z", + "last_commit": "2026-10-08T21:34:00Z" + }, + { + "login": "leblanc-simon", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 4, + "lines_added": 3721, + "lines_deleted": 60, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T22:00:03Z", + "last_commit": "2026-10-08T23:14:11Z" + }, + { + "login": "dotCooCoo", + "people_entry": true, + "github_name": ".CooCoo", + "prs": 5, + "commits": 6, + "lines_added": 1701, + "lines_deleted": 139, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T22:18:57Z", + "last_commit": "2026-10-09T15:33:59Z" + }, + { + "login": "u9151131269-a11y", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 1, + "lines_added": 37, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T23:05:11Z", + "last_commit": "2026-10-08T23:05:11Z" + }, + { + "login": "HEOJUNFO", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 112, + "lines_deleted": 17, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T23:49:03Z", + "last_commit": "2026-10-08T23:49:03Z" + }, + { + "login": "DisasterAreaDesigns", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 14, + "lines_added": 3359, + "lines_deleted": 116, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T00:15:28Z", + "last_commit": "2026-10-09T14:43:23Z" + }, + { + "login": "jax-spenser", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 3098, + "lines_deleted": 39, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T01:41:46Z", + "last_commit": "2026-10-09T01:41:46Z" + }, + { + "login": "mateususa", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 18, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T02:19:59Z", + "last_commit": "2026-10-09T02:19:59Z" + }, + { + "login": "fastmikey99", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 5, + "lines_added": 490, + "lines_deleted": 31, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T04:11:07Z", + "last_commit": "2026-10-09T04:11:08Z" + }, + { + "login": "AutoHenry", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 351, + "lines_deleted": 27, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T10:50:26Z", + "last_commit": "2026-10-09T10:50:26Z" + }, + { + "login": "jmverges", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 382, + "lines_deleted": 73, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T11:17:50Z", + "last_commit": "2026-10-09T19:03:09Z" + }, + { + "login": "dmatviichuk", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 3893, + "lines_deleted": 109, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T12:15:12Z", + "last_commit": "2026-10-09T17:51:43Z" + }, + { + "login": "hamada147", + "people_entry": true, + "github_name": "Ahmed Moussa", + "prs": 1, + "commits": 2, + "lines_added": 671, + "lines_deleted": 62, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T13:09:34Z", + "last_commit": "2026-10-09T14:18:54Z" + }, + { + "login": "haranishi", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 571, + "lines_deleted": 7, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:53:07Z", + "last_commit": "2026-10-09T17:53:07Z" + }, + { + "login": "Messhias", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 268, + "lines_deleted": 27, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T19:00:03Z", + "last_commit": "2026-10-09T19:26:55Z" + }, + { + "login": "rschuerer", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 450, + "lines_deleted": 35, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:08:33Z", + "last_commit": "2026-10-09T23:08:33Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 34, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:04:42Z", + "last_commit": "2026-10-10T00:04:42Z" + }, + { + "login": "llduyll10", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, + { + "login": "visuarte", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null } ], "models": [ @@ -313,36 +729,72 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 567, - "lines_added": 382239, - "lines_deleted": 67174 + "commits": 787, + "lines_added": 439476, + "lines_deleted": 75146 }, { "id": "anthropic/claude-sonnet/5.5", "company": "Anthropic", "model": "Claude Sonnet", "version": "5.5", - "commits": 6, - "lines_added": 660, - "lines_deleted": 34 - }, - { - "id": "anthropic/claude-opus/4.8", - "company": "Anthropic", - "model": "Claude Opus", - "version": "4.8", - "commits": 3, - "lines_added": 1672, - "lines_deleted": 42 + "commits": 22, + "lines_added": 7688, + "lines_deleted": 196 }, { "id": "anthropic/claude-fable/5.1", "company": "Anthropic", "model": "Claude Fable", "version": "5.1", + "commits": 9, + "lines_added": 8008, + "lines_deleted": 2754 + }, + { + "id": "anthropic/claude-opus/4.8", + "company": "Anthropic", + "model": "Claude Opus", + "version": "4.8", + "commits": 4, + "lines_added": 1729, + "lines_deleted": 44 + }, + { + "id": "openai/codex", + "company": "OpenAI", + "model": "Codex", + "version": "", + "commits": 3, + "lines_added": 448, + "lines_deleted": 120 + }, + { + "id": "anysphere/cursor", + "company": "Anysphere", + "model": "Cursor", + "version": "", + "commits": 2, + "lines_added": 63, + "lines_deleted": 25 + }, + { + "id": "anthropic/claude", + "company": "Anthropic", + "model": "Claude", + "version": "", "commits": 1, - "lines_added": 6809, - "lines_deleted": 2627 + "lines_added": 55, + "lines_deleted": 4 + }, + { + "id": "anthropic/claude-opus/4.7", + "company": "Anthropic", + "model": "Claude Opus", + "version": "4.7", + "commits": 1, + "lines_added": 94, + "lines_deleted": 12 } ] } From f2ba41d1c18054cc252576da44db8c58d54eb7d1 Mon Sep 17 00:00:00 2001 From: Reinhard Schuerer Date: Sat, 10 Oct 2026 01:50:56 +0200 Subject: [PATCH 32/43] Open .ai files whose non-native art has ASCII85 lines starting with %_ A letterhead saved by Illustrator 28 without PDF compatibility wouldn't open: "its editing data can't be read (it ends inside a layer)". Its layer starts with a /ForeignObject (non-native art: the content of a placed PDF), whose /Data entry is the PDF as ASCII85 in comment lines. ASCII85 includes `_`, so about one line in 85 starts with `%_`, which the lexer reads as hidden tokens; a `(` in such a line opened a string that swallowed the dictionary's end, the rest of the art and the layer's LB. The reader now skips to the data's `~>` after `/Data ,` in a /ForeignObject, as it already did for a /Binary dictionary's ASCII85, and leaves the non-native art out with a note (nothing draws its PDF from the editing data yet). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch --- crates/eps/src/import/ai/read.rs | 15 +++++++++++++-- crates/eps/src/import/ai/tests.rs | 12 ++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 31d1c965..a068a397 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -311,6 +311,9 @@ const SKIPPED: &[(&str, &str)] = &[ ("Alternate_Content", "End_Versioned_Content"), ]; +/// The note for a `/ForeignObject`, art the editor shows but doesn't edit. +pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet"; + /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → /// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`. fn marker(c: &str) -> &str { @@ -645,8 +648,13 @@ impl<'a> Reader<'a> { "," => { if let Some(Frame { kind: Kind::Obj(o), base, .. }) = self.frames.last_mut() { obj::add_entry(o, &mut self.stack, *base); - // Binary data in ASCII85 follows, up to its `~>`. - if o.ty == "Binary" && o.entries.last().is_some_and(|(k, _)| k.as_deref() == Some("ASCII85Decode")) { + // Binary data in ASCII85 follows, up to its `~>`: a `/Binary` dictionary's + // (`/ASCII85Decode ,`) or a non-native art object's (`/ForeignObject`'s + // `/Data ,`, the PDF it keeps), in comment lines. ASCII85 has `_`, so a line + // of it may start with `%_` and read as hidden tokens: a `(` there would open + // a string that swallows the rest of the layer. + let key = o.entries.last().and_then(|(k, _)| k.as_deref()); + if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) { self.lex.skip_past(b"~>"); } } @@ -826,6 +834,9 @@ impl<'a> Reader<'a> { "Document" => self.document(&o), "AI11Text" => self.text_slot(&o)?, "SymbolInstance" => self.unreadable("symbols"), + // Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in + // the dictionary, which nothing draws from yet, so the file opens without it. + "ForeignObject" => self.warn(NON_NATIVE_ART), _ => {} } Ok(()) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index bb4429df..87eb5a1b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -329,6 +329,18 @@ fn sections_it_skips() { assert_eq!(art(&doc).len(), 1); } +/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after +/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden +/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator +/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note. +#[test] +fn non_native_art_is_skipped_with_its_ascii85_data() { + let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@\n;\n0 0 Xd\n6 () XW\n0 Ae\n"; + let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))); + assert_eq!(art(&s.doc).len(), 1); + assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings); +} + #[test] fn cmyk_documents() { let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc; diff --git a/docs/mcp.md b/docs/mcp.md index c4ee8a06..5be2e57a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -450,7 +450,8 @@ CMYK too. PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder page) opens from its editing data alone: its type is made from the file's text document where it can be (point type), and -what can't be is left out with a warning; without editing data it says it can't be opened. +what can't be is left out with a warning; so is non-native art (the content of a placed PDF, which Illustrator shows but +doesn't edit and keeps as a PDF inside the editing data); without editing data it says it can't be opened. What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art; the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout, From 1d0305fc2a563beafb6170801aa115510209147b Mon Sep 17 00:00:00 2001 From: fastmikey99 <218057203+fastmikey99@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:11:07 +1300 Subject: [PATCH 33/43] PDF import: clip art reaching past the page to the crop box A clip to the page box is dropped as redundant (by hayro, and by push_clip_path), so art a file hides outside the page - a mock-up backdrop thousands of points across, say - came in unclipped: it showed, and it sized Fit to Artwork Bounds and CropTo::Bounding to metres. Viewers show a page through its crop box, so each object reaching past it now keeps its own clip to it, with a warning. Art inside the page is unchanged (no clip groups). Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import.rs | 24 ++++++++++++++++++++++++ crates/pdf/src/tests_import_fidelity.rs | 18 ++++++++++++++++++ crates/pdf/src/tests_import_layers.rs | 3 ++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index b59f28c3..a570db28 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -135,6 +135,9 @@ fn group_layer( pub const OFF_ARTBOARD_NOTE: &str = "only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open"; +/// Warning: art reaching past the page box was clipped to it. +pub(crate) const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, as PDF viewers show it"; + /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; @@ -227,6 +230,27 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); placeholder &= ai && only_text(&children); let mut right = ab.x1; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index b6098eed..1881bfa4 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -446,3 +446,21 @@ fn a_stroke_under_a_stretched_transform_stays_a_stroke() { let b = objs[0].geometric_bounds().unwrap(); assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6, "{b:?}"); } + +#[test] +fn art_past_the_page_clip_keeps_the_clip() { + // A page-sized clip, then a backdrop far larger than the page and a square on it: the square + // needs no clip, the backdrop keeps it, so the art's bounds stay on the page. + let r = open_with(&one_page("0 0 100 100 re W n 0 g -500 -500 1100 1100 re f 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); + let d = &r.document; + let art = d.art_bounds().expect("art"); + assert!(art.x0 >= -0.01 && art.y0 >= -0.01 && art.x1 <= 100.01 && art.y1 <= 100.01, "the art stays on the page: {art:?}"); + let clips: Vec = all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + let [c] = clips.as_slice() else { panic!("one clip group, the backdrop's: {clips:?}") }; + assert_eq!(c.children().map(Vec::len), Some(2), "the clip path and the backdrop"); + assert!(r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); + // Art inside the page gets no clip and no warning. + let r = open_with(&one_page("0 0 100 100 re W n 1 0 0 rg 10 10 20 20 re f", "", &[]), |_| {}); + assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))); + assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings); +} diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 937a65d9..690ff1b0 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -165,9 +165,10 @@ fn an_ai_file_notes_that_its_art_off_the_artboards_is_not_read() { } } +/// Paths that are art (not clipping paths: art reaching past the page keeps a clip to it). fn path_count(d: &Document) -> usize { let mut n = 0; - d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); + d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { clipping: false, .. }))); n } From c2339fca817dc7bed45b785594ff8609256dcd73 Mon Sep 17 00:00:00 2001 From: fastmikey99 <218057203+fastmikey99@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:48:03 +1300 Subject: [PATCH 34/43] PDF import: leave art past the page unclipped in a PDF-compatible .ai The PDF part of an .ai stands in for the editable document, whose art crossing the artboard edge isn't clipped, so it keeps opening whole and editable (#646). Plain PDFs still get the clip to the crop box. path_count is back to counting every path: no clipping paths appear in the .ai cases it counts. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import.rs | 6 ++++-- crates/pdf/src/tests_import_layers.rs | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index a570db28..c4db8f81 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -235,11 +235,13 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result usize { let mut n = 0; - d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { clipping: false, .. }))); + d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); n } @@ -368,6 +367,25 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() { assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings); } +/// Art reaching past the page keeps a clip to it in a plain PDF (as viewers show it), but not in +/// a PDF-compatible `.ai`, whose art crossing the artboard edge opens whole and editable (#646). +#[test] +fn art_past_the_page_is_clipped_in_a_pdf_but_not_in_an_ai_file() { + for ai in [false, true] { + let page = PdfPage { + entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() }, + ..PdfPage::new(100.0, 100.0, "0 0 100 100 re W n 1 0 0 rg 50 50 200 200 re f") + }; + let r = import_with_report(&pdf_with_catalog(&[page], &[], "", None), &ImportOptions::default()).unwrap(); + let mut clips = 0; + r.document.walk(|n| clips += usize::from(matches!(n.kind, NodeKind::Group { clip: true, .. }))); + let past = r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE); + assert_eq!((clips, past), if ai { (0, false) } else { (1, true) }, "ai: {ai}: {:?}", r.warnings); + let art = r.document.art_bounds().unwrap(); + assert_eq!(art.x1 > 100.5, ai, "ai: {ai}: {art:?}"); + } +} + /// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order. #[test] fn an_ai_files_private_data_is_its_streams_joined() { From d38a2fa023527d9aaf37cc9b01a6736abd93edee Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 08:30:48 +0700 Subject: [PATCH 35/43] Review fixes for #675: ROADMAP entry for the PDF page clip Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c832adf9..8db60e4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593), an image copied in a browser pasting as the picture, not its address (#597), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585), the font menus' live preview staying on the text instead of flickering on and off every frame (#563), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530), the same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579), SVG layers keeping their names when wrapped and sublayers round-tripping through SVG (#372), a Layers panel that lays out only the rows in view and keeps a document's open rows in its file (#638), a panel dropped on the dock lights up the icon column it goes back to and pops out of its icon (#649), Image Trace's View modes and an Advanced section that stays as it was left (part of #641), Image Trace's Palette: Limited, Full Tone, Automatic and Document Library (part of #641), Image Trace's Create Strokes: line art traced as stroked centre lines (part of #641), a dual-GPU Mac falling back to its other GPU when one fails to start (#651), two fingers dragged on a touch screen panning the canvas (part of #449), the Home screen's Recent Files and a double-clicked New Document preset creating the document (part of #663), quitting no longer crashing on Macs with a Touch Bar (#661), Animated Zoom, Large Tabs, Scale Cursor Proportional to UI and anti-aliased bitmaps in Pixel Preview acting (part of #394), Smart Guides › Spacing Guides (part of #394), Edit Artboards' Properties choosing an artboard's size preset and flipping it between portrait and landscape (#671), Preferences › Type › Additional Fonts Folder loading the fonts of a folder you choose (#683), Canvas Color's light, medium and dark grey (part of #663), a document tab's close box after its title on Windows and Linux, before it on macOS (part of #673), Rearrange All Artboards' layouts (a grid by row or by column, one row, one column) and right-to-left order, one click away in the Properties and Artboards panels (#681), User Interface › Show Tool Group Labels and Cmd+, opening Settings on the Mac (part of #663), the Windows taskbar and Alt+Tab showing VectorCraft's icon, a click with a selection tool making its artboard the active one, and Paste in Place, in Front and in Back pasting onto the active artboard (#693), Photoshop-style raster effects on a pixel-filter pipeline: Effect › Blur › Radial Blur and Smart Blur, Effect › Sharpen › Unsharp Mask (part of gap 2), the Properties panel's number fields all one width (#696), the rotate cursor drawn as a curved double arrow (#701), the Templates folder in the desktop's own documents folder, made when missing (#703), PDF and .ai glyphs whose ToUnicode names no character (U+FFFD) taking their character from the embedded font or keeping their outlines, with a warning (#708), Illustrator EPS and `.ai` files opening from their editing data with their colours, gradients, transparency, images, names and every artboard (part of #637), `.ai` files saved without PDF compatibility opening from their editing data (part of #637), guides left out of raster exports and Fit to Artwork Bounds, hidden art left out of Fit to Artwork Bounds, Illustrator files compared with their page object by object, and `editingData: false` opening only what the page prints (#735), art reaching past a PDF's page clipped to its crop box (#675); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -166,7 +166,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From 8edee45588b57116011f8357e9a1b1d581f622b7 Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:20:51 +0400 Subject: [PATCH 36/43] Use File Open path for native SVG drops Route native SVG drops through the same reader as File > Open. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 523df4cb..08cfa6d4 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,11 @@ Graphics Processor** and restart. If a graphics processor can't show the window, next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low`), or `WGPU_ADAPTER_NAME` to part of its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)). +On native Wayland, winit 0.30 does not deliver dropped files. To open an SVG, use File › Open; +to place artwork in the current document, copy and paste the file in a file manager; or run +under XWayland (`WAYLAND_DISPLAY= vectorcraft`) for file drag-and-drop +(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). + On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). From b6625f69647017feaaaffdefbb257fad5d588854 Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:21:37 +0400 Subject: [PATCH 37/43] Open dropped SVG paths via the shared reader --- crates/ui-egui/src/place.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 047b28ab..13e7d9f4 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -348,11 +348,14 @@ pub fn drop_files(app: &mut VectorcraftApp, mut files: Vec<(DropTarget, DropFile p["link"] = json!(!embed); run(app, &p).map(|_| ()) } - DropTarget::Open => io::open_bytes(app, &name, &bytes, path.clone()).map(|_| { - if let Some(path) = &path { - io::note_recent(app, path); - } - }), + DropTarget::Open => match path.as_deref() { + Some(path) if app.services.read.is_some() => io::open_path(app, path).map(|_| ()), + _ => io::open_bytes(app, &name, &bytes, path.clone()).map(|_| { + if let Some(path) = &path { + io::note_recent(app, path); + } + }), + }, }; if let Err(e) = r { app.status(if target == DropTarget::Open { format!("Couldn't open {name}: {e}") } else { format!("Couldn't place {name}: {e}") }); From 8b95f79f3bc6b67d493ce70d458908856a6e262a Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:22:00 +0400 Subject: [PATCH 38/43] Handle pathless SVG drops by sniffing bytes --- crates/ui-egui/src/lib.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b60c56c..89086af3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -886,11 +886,20 @@ impl VectorcraftApp { for f in dropped { let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); - let target = self.drop_target(&name, pos, shift); - // A file placed by its path is read by the engine. - let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() }; + // Native file drops usually carry a path. Use the same file reader as File → Open + // (and File → Place), even if the drop handle can't supply the bytes itself. + let bytes = if path.is_some() && self.services.read.is_some() { Ok(vec![]) } else { f.bytes() }; match bytes { - Ok(b) => files.push((target, (name, path, b))), + Ok(bytes) => { + // A backend may give bytes without a path or extension. Sniff their format + // before deciding: an SVG opens as a document, a raster image is placed. + let target = if path.is_none() && fileio::detect(&name, &bytes).is_none_or(|f| !f.raster) { + place::DropTarget::Open + } else { + self.drop_target(&name, pos, shift) + }; + files.push((target, (name, path, bytes))); + } Err(e) => self.status(format!("Couldn't read {name}: {e}")), } } From ec32a0b537c12156d9d7a2acdcfab35a7191ab84 Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:22:20 +0400 Subject: [PATCH 39/43] Add tests for SVG drop handling --- crates/ui-egui/src/tests_place.rs | 104 +++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 6e1190d4..5577bd76 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -55,13 +55,33 @@ impl egui::DroppedFile for Dropped { } } +#[derive(Debug)] +struct MockDrop { + path: std::path::PathBuf, + bytes: Result, String>, +} + +impl egui::DroppedFile for MockDrop { + fn path(&self) -> &std::path::Path { + &self.path + } + fn bytes(&self) -> Result, String> { + self.bytes.clone() + } +} + /// One headless frame of the whole window (800×600) with `events` and `dropped` files. -fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec, dropped: &[&str], shift: bool) { +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, dropped: &[&str], shift: bool) { + let files = dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(); + frame_files(app, ctx, events, files, shift); +} + +fn frame_files(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec, dropped: Vec, shift: bool) { events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() })); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, - dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(), + dropped_files: dropped, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { @@ -79,7 +99,7 @@ fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node { } /// A tiny SVG document. -const SVG: &str = r#""#; +const SVG: &str = r#""#; /// Two frames: fonts, then the canvas lays out → the canvas. fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect { @@ -157,6 +177,84 @@ fn a_dropped_document_opens_as_a_tab_of_its_own() { assert_eq!(app.ui.recent_files.first(), Some(&svg)); } +#[test] +fn a_native_svg_drop_uses_file_open_even_with_a_path_only_handle() { + let path = temp_file("path-only.svg", SVG.as_bytes()); + let mut dropped = app(); + let ctx = egui::Context::default(); + laid_out(&mut dropped, &ctx); + let handle = Arc::new(MockDrop { path: path.clone().into(), bytes: Err("only a path".into()) }) as egui::DroppedFileHandle; + frame_files(&mut dropped, &ctx, vec![], vec![handle], false); + + let mut opened = app(); + opened.run("file.open", json!({ "path": path })).unwrap(); + let anchors = |app: &VectorcraftApp| { + let mut count = 0; + app.session.active().unwrap().doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + count += path.anchors().count(); + } + }); + count + }; + assert_eq!(anchors(&dropped), anchors(&opened), "same editable SVG anchors as File > Open"); + assert!(anchors(&dropped) >= 3, "the imported path has editable anchors"); + assert_eq!(dropped.session.active().unwrap().path, opened.session.active().unwrap().path); + assert_eq!(dropped.session.active().unwrap().selection.objects.len(), opened.session.active().unwrap().selection.objects.len()); + assert_eq!(dropped.ui.recent_files, opened.ui.recent_files); +} + +#[test] +fn an_svg_drop_without_a_path_is_detected_from_its_bytes() { + let mut app = app(); + let ctx = egui::Context::default(); + laid_out(&mut app, &ctx); + let svg = Arc::new(MockDrop { path: Default::default(), bytes: Ok(SVG.as_bytes().to_vec()) }) as egui::DroppedFileHandle; + frame_files(&mut app, &ctx, vec![], vec![svg], false); + assert_eq!(app.session.documents().len(), 2, "SVG bytes open as a document, not placed art"); + assert!(app.session.active().unwrap().path.is_none(), "no filesystem path was supplied"); + + frame(&mut app, &ctx, vec![], &[], false); + let image = Arc::new(MockDrop { path: Default::default(), bytes: Ok(png(6, 6, 72.0)) }) as egui::DroppedFileHandle; + frame_files(&mut app, &ctx, vec![], vec![image], false); + assert_eq!(app.session.documents().len(), 2, "an image is placed, not opened"); + assert_eq!(images(&app, 1), 1); +} + +#[test] +fn multiple_svg_drops_open_individually() { + let mut app = app(); + let ctx = egui::Context::default(); + laid_out(&mut app, &ctx); + let a = temp_file("first.svg", SVG.as_bytes()); + let b = temp_file("second.svg", SVG.as_bytes()); + frame(&mut app, &ctx, vec![], &[&a, &b], false); + assert_eq!(app.session.documents().len(), 3); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(b.as_str())); + assert_eq!(&app.ui.recent_files[..2], &[b, a]); +} + +#[test] +fn unsupported_and_unreadable_drops_leave_the_document_open() { + let mut app = app(); + let ctx = egui::Context::default(); + laid_out(&mut app, &ctx); + let invalid = temp_file("not-art.xyz", b"not an image or document"); + frame(&mut app, &ctx, vec![], &[&invalid], false); + assert!(app.ui.status.contains("Couldn't place not-art.xyz"), "{}", app.ui.status); + + let invalid_svg = temp_file("broken.svg", b" Date: Fri, 9 Oct 2026 13:33:48 -0700 Subject: [PATCH 40/43] Fonts: missing fonts are listed when a document opens, with Find Fonts and a folder search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a document opens with fonts that aren't available, fonts used only in its symbols included, VectorCraft lists them in a Missing Fonts dialog after the missing linked file questions, one document at a time, with Find Fonts and Close. Find Fonts opens Type › Find Font on the first missing font of the layers' type, to replace it. After Find Fonts or Close the dialog doesn't open again for that open; when another dialog replaces it, it opens again once that dialog closes. Search Fonts Folder (the Fonts folder File › Package writes next to the document) and Find in Folder… search a folder and its subfolders for the fonts' files, and Add Fonts copies the files the user chooses into a Fonts folder next to the preferences. The desktop app and vectorcraft-cli read that folder with the system's font folders. Type › Find Font has a Find in Folder… button too when fonts the document uses are missing. The web app shows no dialog; a status message says how many fonts aren't available. - text.missingFonts lists each missing family and style once, whatever version the document's type names, and leaves out CSS generic families such as sans-serif. text.findFontFiles searches a folder on up to 8 threads and returns at once; text.findFontFiles {} reports the search, and {stop: true} stops it. A search reads only what the font scan reads of font files (their table directories and name, fvar and OS/2 tables) and, on macOS, of suitcase fonts, and lists a file for a font only when the file makes the font resolve exactly. It does not enter app packages, the Program Files and ProgramData folders, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps, and it skips other hidden folders and does not follow links below the picked folder. It ends after 60 s by default, after 10,000,000 entries listed, with more than 1,000,000 folders waiting, after 10,000 font files read or 1,000 kept, or once every font has a file; Stop and Close end it too. - text.addFontFiles copies only files the last search found. Of those, it copies only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts with their resource fork, at most 256 MB a file and 1 GB in all. It never replaces a file: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf). The fonts are then scanned again, and type set in them redraws. - ui.missingFontsDialog and ui.findFontsInFolder open the dialog for agents; docs/control-protocol.md lists its fields. - File › Package gives a font file whose name has no font extension the extension that matches its data (.ttf, .otf or .ttc). - vectorcraft-text adds set_app_font_dir, which system_font_dirs reads, WantedFonts, which reads a font file's names as the font scan does, is_font_file and is_suitcase. #762 split its face reader, which now takes read budgets: file_face_names_within tries the suitcase reader first, and reader_face_names_within applies the budgets to each font. vectorcraft-format adds write_new_with. Docs: docs/mcp.md, docs/control-protocol.md, ROADMAP. --- ROADMAP.md | 2 +- apps/vectorcraft-cli/src/main.rs | 4 + apps/vectorcraft/src/main.rs | 21 +- apps/vectorcraft/src/prefs_dir.rs | 45 ++ crates/engine/src/cmd/findfiles/mod.rs | 17 + crates/engine/src/cmd/findfiles/rules.rs | 409 +++++++++++ crates/engine/src/cmd/findfiles/tests.rs | 545 ++++++++++++++ crates/engine/src/cmd/findfiles/walk.rs | 666 +++++++++++++++++ crates/engine/src/cmd/fontfiles.rs | 515 +++++++++++++ crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/package.rs | 34 +- crates/engine/src/lib.rs | 14 + crates/engine/src/tests_fontfiles.rs | 401 ++++++++++ crates/engine/tests/font_folder.rs | 77 ++ crates/engine/tests/import_fuzz.rs | 128 +++- crates/format/src/atomic.rs | 103 +++ crates/format/src/lib.rs | 2 +- crates/testkit/src/fonts.rs | 69 ++ crates/testkit/src/lib.rs | 2 + crates/text/src/fontdb.rs | 219 +++++- crates/text/src/lib.rs | 4 +- crates/text/src/tests_sysfonts.rs | 204 ++++++ crates/ui-egui/src/dialogs/missing_fonts.rs | 763 ++++++++++++++++++++ crates/ui-egui/src/dialogs/missing_links.rs | 11 +- crates/ui-egui/src/dialogs/mod.rs | 19 +- crates/ui-egui/src/find_font.rs | 59 +- crates/ui-egui/src/i18n/es.tsv | 37 + crates/ui-egui/src/i18n/fr.tsv | 37 + crates/ui-egui/src/i18n/it.tsv | 37 + crates/ui-egui/src/i18n/ja.tsv | 37 + crates/ui-egui/src/i18n/pt-br.tsv | 19 + crates/ui-egui/src/i18n/ru.tsv | 37 + crates/ui-egui/src/i18n/uk.tsv | 37 + crates/ui-egui/src/i18n/zh-hans.tsv | 19 + crates/ui-egui/src/i18n/zh-hant.tsv | 19 + crates/ui-egui/src/menus.rs | 16 + crates/ui-egui/src/panels/links.rs | 5 +- crates/ui-egui/src/state.rs | 15 + docs/control-protocol.md | 21 + docs/mcp.md | 50 +- 40 files changed, 4673 insertions(+), 49 deletions(-) create mode 100644 apps/vectorcraft/src/prefs_dir.rs create mode 100644 crates/engine/src/cmd/findfiles/mod.rs create mode 100644 crates/engine/src/cmd/findfiles/rules.rs create mode 100644 crates/engine/src/cmd/findfiles/tests.rs create mode 100644 crates/engine/src/cmd/findfiles/walk.rs create mode 100644 crates/engine/src/cmd/fontfiles.rs create mode 100644 crates/engine/src/tests_fontfiles.rs create mode 100644 crates/engine/tests/font_folder.rs create mode 100644 crates/testkit/src/fonts.rs create mode 100644 crates/ui-egui/src/dialogs/missing_fonts.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8db60e4e..924a6cec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 2fb790c0..c7b8c182 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -41,6 +41,9 @@ macro_rules! out { #[path = "../../vectorcraft/src/mac_fonts.rs"] mod mac_fonts; mod perf; +/// The desktop app's settings folder, shared: exports and MCP read the same Fonts folder. +#[path = "../../vectorcraft/src/prefs_dir.rs"] +mod prefs_dir; /// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts. #[cfg(all(windows, not(target_vendor = "win7")))] #[path = "../../vectorcraft/src/system_fonts.rs"] @@ -110,6 +113,7 @@ fn main() -> ExitCode { system_fonts::install(); #[cfg(target_os = "macos")] mac_fonts::install(); + prefs_dir::install_fonts(); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c90bad58..0eb82005 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -28,6 +28,7 @@ mod mac_fonts; mod mac_menu; #[cfg(target_os = "macos")] mod open_documents; +mod prefs_dir; mod printing; #[cfg(all(windows, not(target_vendor = "win7")))] mod system_fonts; @@ -141,27 +142,13 @@ fn discard_marked_text() { /// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS), /// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux). fn prefs_path() -> Option { - prefs_path_for("VectorCraft", "vectorcraft") + prefs_dir::prefs_path_for("VectorCraft", "vectorcraft") } /// The same place under the project's former name (DrawCraft): read once if there are no /// VectorCraft preferences yet, so settings survive the rename. fn legacy_prefs_path() -> Option { - prefs_path_for("DrawCraft", "drawcraft") -} - -fn prefs_path_for(name: &str, lower: &str) -> Option { - let base = if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name)) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name)) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join(lower)) - }; - base.map(|b| b.join("ui.json")) + prefs_dir::prefs_path_for("DrawCraft", "drawcraft") } /// Where the log files live: `logs` in the preferences folder (see `logging`). @@ -397,6 +384,8 @@ fn main() -> std::process::ExitCode { system_fonts::install(); #[cfg(target_os = "macos")] mac_fonts::install(); + // VectorCraft's own Fonts folder, which text.addFontFiles copies fonts into. + prefs_dir::install_fonts(); let mut control_port: Option = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs new file mode 100644 index 00000000..7376e2f0 --- /dev/null +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -0,0 +1,45 @@ +//! Where VectorCraft keeps its settings, `ui.json`, and next to it folders of the user's own +//! (Swatches, Graphic Styles, Fonts, Data Recovery, logs). The desktop app and `vectorcraft-cli` +//! (which shares this file) read the same Fonts folder. + +use std::path::PathBuf; + +/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application +/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and +/// BSD). +pub fn prefs_path_for(name: &str, lower: &str) -> Option { + let base = if cfg!(target_os = "macos") { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name)) + } else if cfg!(windows) { + std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name)) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .map(|c| c.join(lower)) + }; + base.map(|b| b.join("ui.json")) +} + +/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which +/// `text.addFontFiles` copies font files into. The folder is set whether or not +/// `VECTORCRAFT_NO_PREFS` is set, as the Swatches and Graphic Styles folders are. +pub fn install_fonts() { + if let Some(dir) = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts"))) { + vectorcraft_text::set_app_font_dir(dir); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_font_scans_read_the_fonts_folder_next_to_the_settings() { + install_fonts(); + let fonts = prefs_path_for("VectorCraft", "vectorcraft").and_then(|p| Some(p.parent()?.join("Fonts"))); + assert!(fonts.is_some(), "a home folder to keep the settings in"); + assert_eq!(vectorcraft_text::app_font_dir().map(std::path::Path::to_path_buf), fonts); + assert!(vectorcraft_text::system_font_dirs().contains(&fonts.unwrap_or_default())); + } +} diff --git a/crates/engine/src/cmd/findfiles/mod.rs b/crates/engine/src/cmd/findfiles/mod.rs new file mode 100644 index 00000000..7986f204 --- /dev/null +++ b/crates/engine/src/cmd/findfiles/mod.rs @@ -0,0 +1,17 @@ +//! Looking through a folder the user picks, and its subfolders, for files of some kind, on separate +//! threads: up to 8 walker threads share one stack of folders and list each folder once. They +//! follow no links and don't enter other apps' or the system's folders, hidden folders or folders +//! whose contents aren't on the disk ([`Rules`]); the time, the entries listed, the folders queued, +//! the depth and the files read are capped ([`Limits`]). [`start`] returns at once, +//! [`Search::progress`] reports where a search stands and [`Search::stop`] ends it. A search reads +//! the files [`Visitor::wants`] accepts by their names and [`Visitor::worth_reading`] by the files +//! themselves, and keeps those [`Visitor::items`] gives wanted items for. +//! The web has no file system: [`start`] fails there. + +mod rules; +#[cfg(test)] +mod tests; +mod walk; + +pub use rules::{Rules, plain}; +pub use walk::{End, Limits, Progress, Search, Visitor, start, threads}; diff --git a/crates/engine/src/cmd/findfiles/rules.rs b/crates/engine/src/cmd/findfiles/rules.rs new file mode 100644 index 00000000..9ea15388 --- /dev/null +++ b/crates/engine/src/cmd/findfiles/rules.rs @@ -0,0 +1,409 @@ +//! Where a folder search goes: not into other apps' or the system's folders. They are told by +//! their names and places (an app package, a home folder's `Library` or `AppData`, the system +//! folders at the root of a volume), never by the name of an app. Names and paths compare in +//! lowercase. + +use std::path::{Component, Path, PathBuf}; + +/// Extensions of the packages apps and media libraries keep their files in. +const PACKAGE_EXTENSIONS: &[&str] = &[ + "app", + "appex", + "bundle", + "component", + "fcpbundle", + "framework", + "imovielibrary", + "kext", + "musiclibrary", + "photolibrary", + "photoslibrary", + "plugin", + "tvlibrary", + "xpc", +]; + +/// The folders Windows installs programs and keeps their data and the system's in, wherever they +/// are. +const INSTALL_FOLDERS: &[&str] = + &["program files", "program files (x86)", "program files (arm)", "programdata", "$recycle.bin", "system volume information"]; + +/// The folders a home folder keeps apps and their data in (besides its hidden ones). +const HOME_APP_FOLDERS: &[&str] = &["library", "appdata", "applications", "snap"]; + +/// The folders a `Library` folder keeps apps' data in (macOS). +const LIBRARY_APP_FOLDERS: &[&str] = &["application support", "containers", "group containers", "caches", "preferences"]; + +/// The folders beside the home folders that apps share (macOS `Shared`, Windows `Public`). +const SHARED_FOLDERS: &[&str] = &["shared", "public"]; + +/// The system's folders at the root of a macOS volume (or a copy of one). +const MAC_SYSTEM: &[&str] = + &["applications", "bin", "cores", "dev", "etc", "home", "library", "net", "network", "opt", "private", "sbin", "system", "tmp", "usr", "var"]; + +/// The system's folders at the root of a Linux or BSD system (or a copy of one). +const UNIX_SYSTEM: &[&str] = &[ + "bin", + "boot", + "dev", + "efi", + "etc", + "gnu", + "lib", + "lib32", + "lib64", + "libx32", + "lost+found", + "nix", + "opt", + "proc", + "root", + "run", + "sbin", + "snap", + "sys", + "usr", + "var", +]; + +/// The system's folders at the root of a Windows drive (or a copy of one), besides the install +/// folders. +const WINDOWS_SYSTEM: &[&str] = + &["windows", "windows.old", "recovery", "perflogs", "boot", "config.msi", "msocache", "$windows.~bt", "$windows.~ws", "$winreagent", "$sysreset"]; + +/// The most entries of a folder read to tell what kind of folder it is. +const MAX_SIBLINGS: usize = 100_000; + +/// The names of the folders that show a folder is the root of a system ([`system_folders`]). +const SYSTEM_SIGNS: &[&str] = &["users", "system", "library", "applications", "windows", "program files", "etc", "usr", "bin", "sbin"]; + +/// Which of [`SYSTEM_SIGNS`] are among the names of a folder's folders and links. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Siblings(u16); + +impl Siblings { + /// The folders and links named `names` (in any case). + #[cfg(test)] + pub(crate) fn of(names: &[&str]) -> Self { + let mut s = Self::default(); + names.iter().for_each(|n| s.note(n)); + s + } + + /// Note a folder or link named `name` (in any case). + pub(crate) fn note(&mut self, name: &str) { + if let Some(i) = SYSTEM_SIGNS.iter().position(|s| s.eq_ignore_ascii_case(name)) { + self.0 |= 1 << i; + } + } + + /// Whether a folder or link named `name`, one of [`SYSTEM_SIGNS`], was noted. + fn has(self, name: &str) -> bool { + SYSTEM_SIGNS.iter().position(|s| *s == name).is_some_and(|i| self.0 & (1 << i) != 0) + } +} + +/// Why a search may not start in a folder ([`Rules::refusal`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Refusal { + /// The folder is in other apps' or the system's folders, or in an app package. + Apps, + /// The folder is in the Shared or Public folder of a `Users` folder. + Shared, +} + +/// A folder a walk lists, as the rules see it ([`Rules::listed`]). +pub(crate) struct Listed { + /// The folder, in lowercase. + dir: PathBuf, + /// Its name, in lowercase. + name: String, + /// The system folders in it, when it is the root of a system. + system: Option<&'static [&'static str]>, + /// It is a home folder. + home: bool, +} + +/// Where a search may go. +#[derive(Clone, Debug, Default)] +pub struct Rules { + /// The home folder (canonical). + pub home: Option, + /// Folders searched when picked, whatever holds them (canonical): the temporary folders, and + /// tests' folders. + pub user_content: Vec, + /// Other apps' and the system's folders the environment names (canonical): never entered, and a + /// search of a folder inside one fails. + pub refused: Vec, +} + +/// `path` in lowercase and, on Windows, without its `\\?\` prefix ([`unprefixed`]), for comparing +/// paths. +fn lower(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + let plain = if cfg!(windows) { unprefixed(&text) } else { None }; + PathBuf::from(plain.as_deref().unwrap_or(&text).to_lowercase()) +} + +/// The system's folders a volume's root holds, when its folders (`siblings`) show it is the root of +/// an operating system. +fn system_folders(siblings: Siblings) -> Option<&'static [&'static str]> { + let has = |name: &str| siblings.has(name); + if has("users") && (has("system") || has("library") || has("applications")) { + Some(MAC_SYSTEM) + } else if has("windows") && (has("users") || has("program files")) { + Some(WINDOWS_SYSTEM) + } else if has("etc") && has("usr") && (has("bin") || has("sbin")) { + Some(UNIX_SYSTEM) + } else { + None + } +} + +/// What the names of the folders and links in `dir` show ([`Siblings`]). +fn folder_siblings(dir: &Path) -> Siblings { + let mut siblings = Siblings::default(); + for e in std::fs::read_dir(dir).into_iter().flatten().take(MAX_SIBLINGS).flatten() { + let name = e.file_name(); + if let Some(name) = name.to_str() + && e.file_type().is_ok_and(|t| t.is_dir() || t.is_symlink()) + { + siblings.note(name); + } + } + siblings +} + +/// The first `n` components of `path`. +fn prefix(path: &Path, n: usize) -> PathBuf { + path.components().take(n).collect() +} + +/// `path` without the `\\?\` prefix that `std::fs::canonicalize` gives paths on Windows: `C:\…` +/// for `\\?\C:\…` and `\\server\share\…` for `\\?\UNC\server\share\…`, when every name in it can +/// be written without the prefix ([`without_verbatim`]). Other paths, and paths on other systems, +/// are returned as they are. +pub fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + match path.to_str().and_then(without_verbatim) { + Some(p) => PathBuf::from(p), + None => path, + } +} + +/// The Windows path `p` without its `\\?\` prefix, when it has one for a drive or a share: `C:\…` +/// for `\\?\C:\…` and `\\server\…` for `\\?\UNC\server\…`. +pub(crate) fn unprefixed(p: &str) -> Option { + if let Some(unc) = p.strip_prefix(r"\\?\UNC\") { + return Some(format!(r"\\{unc}")); + } + let disk = p.strip_prefix(r"\\?\")?; + matches!(disk.as_bytes(), [d, b':', b'\\', ..] if d.is_ascii_alphabetic()).then(|| disk.to_string()) +} + +/// [`unprefixed`], when each name in the path can be written without the prefix ([`portable`]): +/// a share's server and share names, and the names below a drive's root or a share. +pub(crate) fn without_verbatim(p: &str) -> Option { + let plain = unprefixed(p)?; + let (names, at_least) = match plain.strip_prefix(r"\\") { + Some(unc) => (unc, 2), + None => (plain.get(3..)?, 0), + }; + let mut names: Vec<&str> = if names.is_empty() { vec![] } else { names.split('\\').collect() }; + // A trailing separator. + if names.len() > at_least && names.last() == Some(&"") { + names.pop(); + } + let ok = names.len() >= at_least && names.iter().all(|n| portable(n)); + ok.then_some(plain) +} + +/// Whether `name` can be a name in a Windows path without the `\\?\` prefix: not `.` or `..`, not +/// ending in a dot or a space, without `<>:"/\|?*` or control characters, and not a device name +/// (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, these with a superscript digit, +/// `CONIN$`, `CONOUT$`), with an extension or not. +fn portable(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).trim_end().to_ascii_lowercase(); + let numbered = |device: &str| { + stem.strip_prefix(device).is_some_and(|n| { + let mut n = n.chars(); + n.next().is_some_and(|c| c.is_ascii_digit() || "¹²³".contains(c)) && n.next().is_none() + }) + }; + let device = ["con", "prn", "aux", "nul", "conin$", "conout$"].contains(&stem.as_str()) || numbered("com") || numbered("lpt"); + !name.is_empty() + && name != "." + && name != ".." + && !name.ends_with(['.', ' ']) + && !name.chars().any(|c| c < ' ' || "<>:\"/\\|?*".contains(c)) + && !device +} + +impl Rules { + /// This computer's: the home folder, the temporary folders, and the app and system folders the + /// environment names. + pub fn system() -> Self { + let canonical = |p: PathBuf| p.is_absolute().then(|| std::fs::canonicalize(&p).ok()).flatten(); + let var = |name: &str| std::env::var_os(name).map(PathBuf::from).and_then(canonical); + let home = var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }); + let user_content = [std::env::temp_dir(), PathBuf::from("/tmp")].into_iter().filter_map(canonical).collect(); + let names: &[&str] = if cfg!(windows) { + &["APPDATA", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "SystemRoot"] + } else { + &[ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "HOST_XDG_CONFIG_HOME", + "HOST_XDG_DATA_HOME", + "HOST_XDG_CACHE_HOME", + "HOST_XDG_STATE_HOME", + ] + }; + let refused = names.iter().filter_map(|n| var(n)).collect(); + Rules { home, user_content, refused } + } + + /// Whether `dir` is a home folder: the home folder, a folder in a `Users` or `home` folder, or + /// a folder the Flatpak document portal exports (`/run/user//doc//`). + fn is_home(&self, dir: &Path) -> bool { + if self.home.as_ref().is_some_and(|h| lower(h) == lower(dir)) { + return true; + } + let above = dir.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().to_lowercase()); + if matches!(above.as_deref(), Some("users" | "home")) { + return true; + } + let parts: Vec = dir.components().map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect(); + matches!(parts.as_slice(), [_, run, user, uid, doc, _, _] if run == "run" && user == "user" && is_number(uid) && doc == "doc") + } + + /// The folder `dir`, whose folders show `siblings`, as the rules see it when a walk lists it: + /// told once for all the folders in it. + pub(crate) fn listed(&self, dir: &Path, siblings: Siblings) -> Listed { + Listed { + dir: lower(dir), + name: dir.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(), + system: system_folders(siblings), + home: self.is_home(dir), + } + } + + /// Whether a search never enters the folder `name` in the listed folder `parent`: an app's + /// package, a Windows install folder, a system folder at the root of a volume, the folders apps + /// share beside the home folders, a `Library` folder's app data, or a home folder's hidden + /// folders and app folders. + pub(crate) fn never_in(&self, parent: &Listed, name: &str) -> bool { + let name = name.to_lowercase(); + let name = name.as_str(); + if Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(|e| PACKAGE_EXTENSIONS.contains(&e)) || INSTALL_FOLDERS.contains(&name) { + return true; + } + if parent.system.is_some_and(|system| system.contains(&name)) { + return true; + } + match parent.name.as_str() { + "users" if SHARED_FOLDERS.contains(&name) => return true, + "library" if LIBRARY_APP_FOLDERS.contains(&name) => return true, + _ => {} + } + parent.home && (name.starts_with('.') || HOME_APP_FOLDERS.contains(&name)) + } + + /// [`Self::never_in`] the folder `parent`, whose folders show `siblings`. + #[cfg(test)] + pub(crate) fn never(&self, parent: &Path, siblings: Siblings, name: &str) -> bool { + self.never_in(&self.listed(parent, siblings), name) + } + + /// The home folder, when the listed folder `parent` is the root of a system whose rules skip + /// the folder the home folder is in (`/usr/home/me` on FreeBSD, `/var/home/me`): a walk of the + /// root reaches it this way. + pub(crate) fn home_below(&self, parent: &Listed) -> Option { + let home = self.home.as_ref()?; + let system = parent.system?; + let below = lower(home).strip_prefix(&parent.dir).ok()?.components().next()?.as_os_str().to_string_lossy().into_owned(); + system.contains(&below.as_str()).then(|| home.clone()) + } + + /// Whether `path` is one of the refused folders. + pub(crate) fn is_refused(&self, path: &Path) -> bool { + let path = lower(path); + self.refused.iter().any(|r| lower(r) == path) + } + + /// The folders that hold user content whatever holds them, as far as they hold `folder`: the + /// home folder, [`Self::user_content`], the cloud storage folders in the home folder's + /// `Library` (an app's own iCloud `Documents` folder too), removable media and a user's runtime + /// folder on Linux (network shares, the document portal). + fn bases(&self, folder: &Path) -> Vec { + let mut v = self.user_content.clone(); + let lowered = lower(folder); + if let Some(home) = &self.home { + v.push(home.clone()); + let library = home.join("Library"); + v.push(library.join("CloudStorage")); + let mobile = library.join("Mobile Documents"); + v.push(mobile.join("com~apple~CloudDocs")); + let depth = mobile.components().count(); + if lowered.starts_with(lower(&mobile)) && lowered.components().nth(depth + 1).is_some_and(|c| c.as_os_str() == "documents") { + v.push(prefix(folder, depth + 2)); + } + } + v.push(PathBuf::from("/run/media")); + let parts: Vec = folder.components().take(4).map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect(); + if matches!(parts.as_slice(), [_, run, user, uid] if run == "run" && user == "user" && is_number(uid)) { + v.push(prefix(folder, 4)); + } + v + } + + /// Why a search may not start in `folder` (canonical), or `None` when it may: the deepest base + /// ([`Self::bases`]) or refused folder that holds it is a refused one, or a folder below that + /// base (or below the root, without one) is one a walk doesn't enter. + pub(crate) fn refusal(&self, folder: &Path) -> Option { + self.refusal_with(folder, folder_siblings) + } + + /// [`Self::refusal`] without listing a folder: the root of a system, told by the folders it + /// holds, isn't recognized. + pub(crate) fn refusal_unlisted(&self, folder: &Path) -> Option { + self.refusal_with(folder, |_| Siblings::default()) + } + + /// [`Self::refusal`], with `siblings` telling what a folder's folders show. + fn refusal_with(&self, folder: &Path, siblings: impl Fn(&Path) -> Siblings) -> Option { + let lowered = lower(folder); + let held = |p: &PathBuf| lowered.starts_with(lower(p)).then(|| p.components().count()); + let base = self.bases(folder).iter().filter_map(held).max(); + let refused = self.refused.iter().filter_map(held).max(); + let start = match (base, refused) { + (_, Some(r)) if base.is_none_or(|b| r >= b) => return Some(Refusal::Apps), + (Some(b), _) => b, + // From the root of the folder's volume. + (None, _) => folder.components().take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)).count(), + }; + let mut parent = prefix(folder, start); + for c in folder.components().skip(start) { + let child = parent.join(c); + let listed = self.listed(&parent, siblings(&parent)); + let name = c.as_os_str().to_string_lossy().to_lowercase(); + if listed.name == "users" && SHARED_FOLDERS.contains(&name.as_str()) { + return Some(Refusal::Shared); + } + if self.never_in(&listed, &name) || self.is_refused(&child) { + return Some(Refusal::Apps); + } + parent = child; + } + None + } +} + +/// Whether `s` is a number (a user id). +fn is_number(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs new file mode 100644 index 00000000..92f462c3 --- /dev/null +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -0,0 +1,545 @@ +//! The folder search: where it goes and where it doesn't, its limits, its threads. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use super::rules::{Siblings, unprefixed, without_verbatim}; +use super::walk::{Pool, start_in}; +use super::*; + +/// The walker threads of these tests' searches, apart from the searches of other tests. +static TESTS: Pool = Pool::new(32); + +/// [`start`] on the threads of [`TESTS`]. +fn begin(folder: PathBuf, rules: Rules, limits: Limits, threads: usize, items: usize, visitor: Arc) -> Search { + start_in(&TESTS, folder, Some(rules), limits, threads, items, visitor).unwrap() +} + +/// Reads `.ttf` files; each is the item its name is in the list. +struct Names(&'static [&'static str]); + +impl Visitor for Names { + fn wants(&self, name: &str) -> bool { + name.ends_with(".ttf") + } + fn items(&self, path: &Path) -> Vec { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + self.0.iter().position(|n| *n == name).into_iter().collect() + } +} + +/// Reads every file, and waits on reading one until its channel sends or closes. +struct Blocked(Mutex>); + +impl Visitor for Blocked { + fn wants(&self, _: &str) -> bool { + true + } + fn items(&self, _: &Path) -> Vec { + let _ = self.0.lock().unwrap().recv(); + vec![] + } +} + +/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where +/// they end in `/`. +fn tree(tag: &str, paths: &[&str]) -> PathBuf { + let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + for p in paths { + let path = root.join(p); + if p.ends_with('/') { + std::fs::create_dir_all(&path).unwrap(); + } else { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"font").unwrap(); + } + } + plain(std::fs::canonicalize(&root).unwrap()) +} + +/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder. +fn rules(root: &Path) -> Rules { + Rules { home: Some(root.join("Users/me")), user_content: vec![root.to_path_buf()], refused: vec![root.join("refused")] } +} + +/// The search's progress once it ended (or after 10 seconds). +fn wait(s: &Search) -> Progress { + let until = Instant::now() + Duration::from_secs(10); + loop { + let p = s.progress(); + if p.end.is_some() || Instant::now() > until { + return p; + } + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// Search `from` in `root` for the files named `names`, to the end. +fn search(root: &Path, from: &str, limits: Limits, threads: usize, names: &'static [&'static str]) -> Progress { + let s = begin(root.join(from), rules(root), limits, threads, names.len(), Arc::new(Names(names))); + wait(&s) +} + +/// The files a search found, relative to `root`, sorted. +fn found(root: &Path, p: &Progress) -> Vec { + let mut v: Vec = p.hits.iter().map(|(f, _)| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect(); + v.sort(); + v +} + +#[test] +fn the_walk_stays_out_of_other_apps_and_the_systems_folders() { + let root = tree( + "roles", + &[ + "a/b/c/one.ttf", + "Users/me/Documents/two.ttf", + "Disk/Fonts/four.ttf", + "Disk/Users/you/Documents/four.ttf", + // Never entered: each of these folders counts as skipped once. + "Program Files/x/three.ttf", + "Program Files (Arm)/x/three.ttf", + "Users/me/Library/Fonts/three.ttf", + "Users/me/.cache/three.ttf", + "Users/me/AppData/Roaming/three.ttf", + "Users/you/snap/three.ttf", + "Users/Shared/x/three.ttf", + "Pictures/P.photoslibrary/three.ttf", + "Tools/Foo.app/Contents/three.ttf", + "Copy/Library/Application Support/x/three.ttf", + ".hidden/three.ttf", + "refused/three.ttf", + "Disk/System/Library/Fonts/three.ttf", + "Disk/Library/Fonts/three.ttf", + "Disk/Applications/three.ttf", + "Disk/Users/you/Library/three.ttf", + ], + ); + let p = search(&root, "", Limits::default(), 3, &["one.ttf", "two.ttf", "three.ttf", "four.ttf"]); + assert_eq!(p.end, Some(End::Done)); + assert_eq!(found(&root, &p), ["Disk/Fonts/four.ttf", "Disk/Users/you/Documents/four.ttf", "Users/me/Documents/two.ttf", "a/b/c/one.ttf"]); + assert_eq!(p.skipped, 16); + assert_eq!((p.read, p.unreadable), (4, 0)); +} + +#[test] +fn a_picked_folder_inside_other_apps_folders_is_refused() { + let root = tree( + "picks", + &[ + "Users/me/Library/Fonts/", + "Users/me/Library/CloudStorage/Box/F/", + "Users/me/Library/Mobile Documents/x~y/Documents/", + "Users/me/Library/Mobile Documents/x~y/Other/", + "Users/me/.config/fonts/", + "Users/Shared/x/", + "Disk/Users/", + "Disk/System/", + "Disk/Library/Fonts/", + "Tools/Foo.app/Contents/", + "refused/sub/", + "Program Files/x/", + "Program Files (Arm)/x/", + "Users/Public/x/", + "a/.b/", + "file.ttf", + ], + ); + for picked in [ + "Users/me/Library/Fonts", + "Users/me/Library/Mobile Documents/x~y/Other", + "Users/me/.config/fonts", + "Disk/Library/Fonts", + "Tools/Foo.app/Contents", + "refused", + "refused/sub", + "Program Files/x", + "Program Files (Arm)/x", + ] { + let p = search(&root, picked, Limits::default(), 1, &[]); + assert_eq!(p.state(), "failed", "{picked}"); + assert!(p.error().is_some_and(|e| e.contains("doesn't search other apps' or the system's folders")), "{picked}: {p:?}"); + } + for picked in ["Users/Shared/x", "Users/Public/x"] { + let p = search(&root, picked, Limits::default(), 1, &[]); + assert!(p.error().is_some_and(|e| e.contains("doesn't search the Shared or Public folder in Users")), "{picked}: {p:?}"); + } + // Cloud folders, a hidden folder of the user's and another system's root are searched when + // picked. + for picked in ["Users/me/Library/CloudStorage/Box/F", "Users/me/Library/Mobile Documents/x~y/Documents", "a/.b", "Disk", "Disk/Users", ""] { + let p = search(&root, picked, Limits::default(), 1, &[]); + assert_eq!((p.state(), p.error()), ("done", None), "{picked}"); + } + let p = search(&root, "file.ttf", Limits::default(), 1, &[]); + assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); + let p = search(&root, "missing", Limits::default(), 1, &[]); + assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); +} + +#[test] +fn the_search_ends_at_its_limits_and_when_stopped() { + let root = tree("limits", &["one.ttf", "two.ttf", "a/b/c/three.ttf", "x/y.txt"]); + let names = &["one.ttf", "two.ttf", "three.ttf", "nowhere.ttf"]; + // The picked folder and the folders in it are listed, not the ones below them. + let p = search(&root, "", Limits { depth: 1, ..Limits::default() }, 2, names); + assert_eq!(p.end, Some(End::Done)); + assert_eq!(found(&root, &p), ["one.ttf", "two.ttf"]); + assert_eq!((p.folders, p.skipped), (3, 1)); + let p = search(&root, "", Limits { entries: 3, ..Limits::default() }, 1, names); + assert_eq!((p.state(), p.stopped()), ("stopped", Some("limit"))); + let p = search(&root, "", Limits { files: 1, ..Limits::default() }, 1, names); + assert_eq!((p.stopped(), p.read), (Some("limit"), 1)); + let p = search(&root, "", Limits { hits: 1, ..Limits::default() }, 1, names); + assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 1)); + // Each item keeps at most `per_item` files. + let root = tree("per-item", &["a/one.ttf", "b/one.ttf", "c/one.ttf"]); + let p = search(&root, "", Limits { per_item: 2, ..Limits::default() }, 1, &["one.ttf", "nowhere.ttf"]); + assert_eq!((p.end, p.hits.len(), p.read), (Some(End::Done), 2, 3)); +} + +#[test] +fn the_search_answers_at_its_deadline_while_a_walker_is_blocked() { + let root = tree("blocked", &["f.ttf"]); + let (tx, rx) = mpsc::channel(); + let s = begin(root.clone(), rules(&root), Limits { seconds: 0.2, ..Limits::default() }, 1, 1, Arc::new(Blocked(Mutex::new(rx)))); + std::thread::sleep(Duration::from_millis(300)); + let p = s.progress(); + assert_eq!((p.state(), p.stopped()), ("stopped", Some("time")), "{p:?}"); + assert!(p.seconds >= 0.2 && p.seconds < 5.0, "{p:?}"); + let (tx2, rx2) = mpsc::channel(); + let s2 = begin(root.clone(), rules(&root), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx2)))); + let until = Instant::now() + Duration::from_secs(10); + while s2.progress().read == 0 && Instant::now() < until { + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!(s2.progress().state(), "searching"); + s2.stop(); + assert_eq!(s2.progress().stopped(), Some("stop")); + drop((tx, tx2)); +} + +/// Every file is item 0; reading the second file waits until the channel closes. +struct SecondBlocks(Mutex<(usize, mpsc::Receiver<()>)>); + +impl Visitor for SecondBlocks { + fn wants(&self, _: &str) -> bool { + true + } + fn items(&self, _: &Path) -> Vec { + let mut read = self.0.lock().unwrap(); + read.0 += 1; + if read.0 == 2 { + let _ = read.1.recv(); + } + vec![0] + } +} + +/// A file is kept as soon as it is read: progress shows it while the rest of its folder is read, +/// and a search that stops or reaches a limit keeps it. +#[test] +fn files_found_are_kept_as_they_are_read() { + let root = tree("kept", &["a.ttf", "b.ttf", "c.ttf"]); + let (tx, rx) = mpsc::channel(); + let s = begin(root.clone(), rules(&root), Limits::default(), 1, 2, Arc::new(SecondBlocks(Mutex::new((0, rx))))); + let until = Instant::now() + Duration::from_secs(10); + while s.progress().read < 2 && Instant::now() < until { + std::thread::sleep(Duration::from_millis(2)); + } + let p = s.progress(); + assert_eq!((p.state(), p.hits.len()), ("searching", 1), "{p:?}"); + s.stop(); + drop(tx); + let p = wait(&s); + assert_eq!(p.stopped(), Some("stop")); + assert!(!p.hits.is_empty(), "{p:?}"); + let p = search(&root, "", Limits { files: 2, ..Limits::default() }, 1, &["a.ttf", "b.ttf", "c.ttf", "nowhere.ttf"]); + assert_eq!((p.stopped(), p.hits.len()), (Some("limit"), 2), "{p:?}"); +} + +#[test] +fn the_search_ends_once_every_item_has_a_file() { + let root = tree("found", &["a/one.ttf", "b/two.ttf", "c/d/e/f/g/other.ttf"]); + let p = search(&root, "", Limits::default(), 2, &["one.ttf", "two.ttf"]); + assert_eq!((p.end.clone(), found(&root, &p)), (Some(End::Found), vec!["a/one.ttf".to_string(), "b/two.ttf".into()])); +} + +#[test] +fn one_walker_and_eight_find_the_same_files_and_every_search_ends() { + let mut paths = vec![]; + for a in 0..6 { + for b in 0..4 { + paths.push(format!("d{a}/e{b}/one.ttf")); + paths.push(format!("d{a}/e{b}/f/two.ttf")); + paths.push(format!("d{a}/e{b}/f/g/skip.txt")); + } + } + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + let root = tree("threads", &paths); + let limits = Limits { per_item: 100, ..Limits::default() }; + let one = search(&root, "", limits, 1, &["one.ttf", "two.ttf", "nowhere.ttf"]); + assert_eq!((one.end.clone(), one.hits.len(), one.folders), (Some(End::Done), 48, 1 + 6 + 24 + 24 + 24)); + for round in 0..25 { + for threads in 1..=8 { + let p = search(&root, "", limits, threads, &["one.ttf", "two.ttf", "nowhere.ttf"]); + assert_eq!(p.end, Some(End::Done), "round {round}, {threads} threads"); + assert_eq!(found(&root, &p), found(&root, &one), "round {round}, {threads} threads"); + } + } +} + +/// Fails on `boom.ttf` with a panic: a bug in reading one file. +struct Boom; + +impl Visitor for Boom { + fn wants(&self, name: &str) -> bool { + name.ends_with(".ttf") + } + fn items(&self, path: &Path) -> Vec { + match path.file_name().and_then(|n| n.to_str()) { + Some("boom.ttf") => panic!("a bug"), + Some("ok.ttf") => vec![0], + _ => vec![1], + } + } +} + +#[test] +fn a_visitor_that_panics_on_one_file_does_not_end_the_search() { + // The file beside the one that trips the bug is found too. + let root = tree("boom", &["a/boom.ttf", "a/ok.ttf", "b/c/fine.ttf"]); + let s = begin(root.clone(), rules(&root), Limits::default(), 2, 3, Arc::new(Boom)); + let p = wait(&s); + assert_eq!((p.end.clone(), found(&root, &p), p.unreadable), (Some(End::Done), vec!["a/ok.ttf".to_string(), "b/c/fine.ttf".into()], 1)); +} + +#[cfg(unix)] +#[test] +fn links_are_not_followed_and_unreadable_folders_count() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let root = tree("links", &["outside/x.ttf", "inner/locked/y.ttf"]); + let inner = root.join("inner"); + symlink(root.join("outside"), inner.join("folder")).unwrap(); + symlink(&inner, inner.join("loop")).unwrap(); + symlink(root.join("outside/x.ttf"), inner.join("x.ttf")).unwrap(); + let locked = inner.join("locked"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Where permissions don't hold (root), the folder reads as any other. + let enforced = std::fs::read_dir(&locked).is_err(); + let p = search(&root, "inner", Limits::default(), 2, &["x.ttf", "y.ttf"]); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(p.end, Some(End::Done)); + if enforced { + assert!(p.hits.is_empty() && p.unreadable == 1, "{p:?}"); + } else { + assert_eq!(found(&root, &p), ["inner/locked/y.ttf"]); + } + // The linked file isn't a file of the folder; y.ttf is one where the locked folder reads. + assert_eq!(p.files, u64::from(!enforced), "{p:?}"); +} + +#[cfg(unix)] +#[test] +fn a_picked_folder_that_cant_be_listed_fails_the_search() { + use std::os::unix::fs::PermissionsExt; + let root = tree("unlistable", &["locked/y.ttf"]); + let locked = root.join("locked"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Where permissions don't hold (root), the folder reads as any other. + let enforced = std::fs::read_dir(&locked).is_err(); + let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + if enforced { + assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + } else { + assert_eq!(found(&root, &p), ["locked/y.ttf"]); + } +} + +#[test] +fn system_roots_and_homes_are_told_by_what_they_hold() { + let r = Rules { home: Some(PathBuf::from("/home/me")), ..Rules::default() }; + let never = |parent: &str, siblings: Siblings, name: &str| r.never(Path::new(parent), siblings, name); + let none = Siblings::default(); + let mac = Siblings::of(&["Users", "Library", "System", "Applications", "Volumes", "opt"]); + for name in ["Library", "System", "Applications", "opt", "private"] { + assert!(never("/", mac, name), "{name}"); + } + for name in ["Users", "Volumes", "Fonts"] { + assert!(!never("/", mac, name), "{name}"); + } + let unix = Siblings::of(&["usr", "etc", "bin", "home", "nix", "srv", "data", "mnt"]); + for name in ["usr", "etc", "bin", "nix", "var", "proc"] { + assert!(never("/", unix, name), "{name}"); + } + for name in ["home", "srv", "data", "mnt", "media"] { + assert!(!never("/", unix, name), "{name}"); + } + let windows = Siblings::of(&["Windows", "Users", "Program Files", "Data"]); + for name in ["Windows", "Program Files", "Recovery"] { + assert!(never("C:\\", windows, name), "{name}"); + } + for name in ["Data", "Users"] { + assert!(!never("C:\\", windows, name), "{name}"); + } + // A project that holds a bin and an etc folder isn't the root of a system. + let project = Siblings::of(&["bin", "etc", "src"]); + assert!(!never("/work/p", project, "bin") && !never("/work/p", project, "etc")); + // Home folders: the home folder, and every folder in a Users or home folder. + for name in ["Library", ".config", "snap", "AppData", "Applications"] { + assert!(never("/home/me", none, name), "{name}"); + assert!(never("/Users/you", none, name), "{name}"); + } + assert!(!never("/home/me", none, "Documents") && !never("/home/me/Documents", none, "Library")); + assert!(never("/run/user/1000/doc/a1b2/me", none, "snap"), "a folder the document portal exports"); + assert!(never("/Users", none, "Shared") && never("/Users", none, "Public")); + assert!(never("/Volumes/Copy/Library", none, "Application Support") && !never("/Volumes/Copy/Library", none, "Fonts")); + assert!(never("/x", none, "Foo.app") && never("/x", none, "Program Files (x86)") && !never("/x", none, "Fonts.d")); + assert!(never("/x", none, "Program Files (Arm)")); + // A system root's walk reaches a home folder below a folder it skips (FreeBSD's /usr/home). + let bsd = Rules { home: Some(PathBuf::from("/usr/home/me")), ..Rules::default() }; + let root = Path::new("/"); + assert_eq!(bsd.home_below(&bsd.listed(root, unix)), Some(PathBuf::from("/usr/home/me"))); + assert_eq!(r.home_below(&r.listed(root, unix)), None); + assert_eq!(bsd.home_below(&bsd.listed(root, project)), None); +} + +#[test] +fn a_search_starts_with_the_threads_left_and_fails_when_none_is() { + static POOL: Pool = Pool::new(3); + let root = tree("pool", &["f.ttf"]); + let blocked = || { + let (tx, rx) = mpsc::channel(); + (tx, Arc::new(Blocked(Mutex::new(rx))) as Arc) + }; + let (tx_a, a) = blocked(); + let (tx_b, b) = blocked(); + let a = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 2, 1, a).unwrap(); + let b = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 4, 1, b).unwrap(); + assert_eq!(POOL.live(), 3); + let (_, c) = blocked(); + let c = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 1, 1, c); + assert_eq!(c.err().as_deref(), Some("the search couldn't start: try again in a moment")); + drop((tx_a, tx_b)); + assert_eq!((wait(&a).end, wait(&b).end), (Some(End::Done), Some(End::Done))); + let until = Instant::now() + Duration::from_secs(10); + while POOL.live() > 0 && Instant::now() < until { + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!(POOL.live(), 0, "every walker thread ended"); + let s = start_in(&POOL, root.clone(), Some(rules(&root)), Limits::default(), 8, 1, Arc::new(Names(&[]))).unwrap(); + assert_eq!(wait(&s).end, Some(End::Done)); +} + +#[test] +fn a_search_takes_the_cpus_the_renderer_leaves_from_two_to_eight() { + use super::walk::threads_for; + assert_eq!([threads_for(64, 4), threads_for(12, 4), threads_for(4, 4), threads_for(1, 0)], [8, 7, 2, 2]); + assert!((2..=8).contains(&threads())); +} + +/// A folder holding more folders than a search queues ends the search while it is being listed. +#[test] +fn a_folder_with_more_folders_than_the_search_queues_ends_it_as_it_is_listed() { + // Folders and files made in turns: whatever order a file system lists them in, about half of a + // folder's first entries are folders. + let names: Vec = (0..2048).flat_map(|i| [format!("d{i}/"), format!("f{i}.txt")]).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let root = tree("many-folders", &names); + let p = search(&root, "", Limits { folders: 10, ..Limits::default() }, 1, &[]); + assert_eq!(p.end, Some(End::Limit)); + assert!(p.files < 2048, "the folder was listed to its end: {p:?}"); +} + +/// Wants every file, counting the files it is asked about. +struct Counted(AtomicUsize); + +impl Visitor for Counted { + fn wants(&self, _: &str) -> bool { + self.0.fetch_add(1, Ordering::Relaxed); + true + } + fn items(&self, _: &Path) -> Vec { + vec![] + } +} + +/// A folder's files past those the search reads aren't kept to be read. +#[test] +fn files_past_those_the_search_reads_are_not_kept() { + let names: Vec = (0..50).map(|i| format!("f{i}.ttf")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let root = tree("many-files", &names); + let counted = Arc::new(Counted(AtomicUsize::new(0))); + let p = wait(&begin(root.clone(), rules(&root), Limits { files: 3, ..Limits::default() }, 1, 1, counted.clone())); + assert_eq!((p.stopped(), p.read, p.files), (Some("limit"), 3, 50), "{p:?}"); + assert_eq!(counted.0.load(Ordering::Relaxed), 4, "one more than the files read"); +} + +/// Wants `.ttf` files by their names, turns down those whose names start with `skip` by the file, +/// and finds `font.ttf`. +struct Picky; + +impl Visitor for Picky { + fn wants(&self, name: &str) -> bool { + name.ends_with(".ttf") + } + fn worth_reading(&self, path: &Path) -> bool { + !path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("skip")) + } + fn items(&self, path: &Path) -> Vec { + if path.ends_with("font.ttf") { vec![0] } else { vec![] } + } +} + +/// Files a visitor turns down by the file itself aren't read and don't count among the files +/// read: with room to read one file, the search gets past them to the font in the folder below. +#[test] +fn files_turned_down_by_the_file_are_not_read() { + let root = tree("turned-down", &["skip1.ttf", "skip2.ttf", "skip3.ttf", "sub/font.ttf"]); + let p = wait(&begin(root.clone(), rules(&root), Limits { files: 1, ..Limits::default() }, 1, 1, Arc::new(Picky))); + assert_eq!((p.end.clone(), p.read, found(&root, &p)), (Some(End::Found), 1, vec!["sub/font.ttf".to_string()]), "{p:?}"); +} + +/// Windows paths lose the `\\?\` prefix `canonicalize` gives them where they read the same +/// without it. Paths compare the same with the prefix and without it. +#[test] +fn windows_paths_lose_the_verbatim_prefix_where_they_read_the_same() { + for (verbatim, without) in [ + (r"\\?\C:\Users\me\Fonts", r"C:\Users\me\Fonts"), + (r"\\?\d:\", r"d:\"), + (r"\\?\UNC\server\share\a b\c.otf", r"\\server\share\a b\c.otf"), + (r"\\?\UNC\server\share", r"\\server\share"), + (r"\\?\UNC\server\share\", r"\\server\share\"), + ] { + assert_eq!(without_verbatim(verbatim).as_deref(), Some(without), "{verbatim}"); + } + // For comparing, any name goes. + assert_eq!(unprefixed(r"\\?\C:\a.\b").as_deref(), Some(r"C:\a.\b")); + assert_eq!(unprefixed(r"\\?\UNC\server").as_deref(), Some(r"\\server")); + assert_eq!((unprefixed(r"\\?\C:"), unprefixed(r"C:\a"), unprefixed("/home/me")), (None, None, None)); + for kept in [ + r"\\?\C:", + r"\\?\C:\a.\b", + r"\\?\C:\a \b", + r"\\?\C:\CON\b", + r"\\?\C:\a\nul.txt", + r"\\?\C:\a\COM¹", + r"\\?\C:\a/b", + r"\\?\C:\a\..\b", + r"\\?\C:\a\\b", + r"\\?\Volume{00000000-0000-0000-0000-000000000000}\a", + r"\\?\UNC\server", + r"C:\a", + "/home/me", + ] { + assert_eq!(without_verbatim(kept), None, "{kept}"); + } + if !cfg!(windows) { + assert_eq!(plain(PathBuf::from(r"\\?\C:\a")), PathBuf::from(r"\\?\C:\a"), "other systems' paths stay as they are"); + } +} diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs new file mode 100644 index 00000000..dc20a0e8 --- /dev/null +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -0,0 +1,666 @@ +//! The walker threads of a folder search and what they share. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use super::rules::{Refusal, Rules, Siblings, plain}; + +/// The most walker threads one search starts. +const MAX_THREADS: usize = 8; + +/// The most walker threads all searches together have running. +const MAX_LIVE: usize = 32; + +/// How often a walker with nothing to do looks again (the deadline, the end). +const IDLE: Duration = Duration::from_millis(50); + +/// How many entries of a folder a walker lists between looks at the end, the deadline and the +/// folders waiting to be listed. +const CHECK_EVERY: u64 = 1024; + +/// How a search ends when every walker thread stopped on a bug. +const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; + +/// What a search looks for. +pub trait Visitor: Send + Sync { + /// Whether the regular file named `name` is to be read: asked of the files a folder lists until + /// it has one more of them to read than [`Limits::files`]. + fn wants(&self, name: &str) -> bool; + /// Whether the file at `path`, which [`Self::wants`] took by its name, is to be read after all, + /// by what the file itself is. A file turned down here doesn't count among the files read. + fn worth_reading(&self, _path: &Path) -> bool { + true + } + /// The wanted items (indexes below the search's item count) the file at `path` is. + fn items(&self, path: &Path) -> Vec; +} + +/// The caps on a search. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Limits { + /// Seconds before it ends (0 to 3600; 60 when not finite). + pub seconds: f64, + /// Entries listed, in every folder. + pub entries: u64, + /// Folders waiting to be listed. + pub folders: usize, + /// Folders below the picked one. + pub depth: u32, + /// Files read. + pub files: u64, + /// Files kept for each item. + pub per_item: usize, + /// Files kept in all. + pub hits: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { seconds: 60.0, entries: 10_000_000, folders: 1_000_000, depth: 64, files: 10_000, per_item: 10, hits: 1_000 } + } +} + +/// How a search ended. +#[derive(Clone, Debug, PartialEq)] +pub enum End { + /// Every folder it could enter was searched. + Done, + /// Every item has a file. + Found, + /// [`Search::stop`]. + Stopped, + /// [`Limits::seconds`] passed. + Time, + /// The entries listed, the folders waiting to be listed, the files read or the files kept + /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item + /// keeps at most [`Limits::per_item`] files, without ending the search. + Limit, + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. + Failed(String), +} + +/// Where a search stands ([`Search::progress`]). +#[derive(Clone, Debug, Default)] +pub struct Progress { + /// `None` while it runs. + pub end: Option, + /// The folder searched (canonical, through [`plain`]), once it was checked. The walk reads the + /// folder and the files below it by the paths with the `\\?\` prefix that Windows gives them. + pub folder: PathBuf, + /// The files found, each with the items it is, in the order found (through [`plain`]). + pub hits: Vec<(PathBuf, Vec)>, + /// Folders listed. + pub folders: u64, + /// Files listed. + pub files: u64, + /// Files read. + pub read: u64, + /// Folders not entered (other apps', the system's, hidden ones, ones too deep, ones whose + /// contents aren't on the disk, ones whose names can't be read). + pub skipped: u64, + /// Folders and files that couldn't be read. + pub unreadable: u64, + /// How long it ran, or has been running. + pub seconds: f64, +} + +impl Progress { + /// `searching`, `done` ([`End::Done`], [`End::Found`]), `stopped` ([`End::Stopped`], + /// [`End::Time`], [`End::Limit`]) or `failed`. + pub fn state(&self) -> &'static str { + match &self.end { + None => "searching", + Some(End::Done | End::Found) => "done", + Some(End::Stopped | End::Time | End::Limit) => "stopped", + Some(End::Failed(_)) => "failed", + } + } + + /// Why a stopped search stopped: `stop`, `time` or `limit`. + pub fn stopped(&self) -> Option<&'static str> { + match &self.end { + Some(End::Stopped) => Some("stop"), + Some(End::Time) => Some("time"), + Some(End::Limit) => Some("limit"), + _ => None, + } + } + + /// Why a failed search failed. + pub fn error(&self) -> Option<&str> { + match &self.end { + Some(End::Failed(e)) => Some(e), + _ => None, + } + } +} + +/// Walker threads shared out among searches: all of them together run at most `cap` threads. +pub(crate) struct Pool { + live: AtomicUsize, + cap: usize, +} + +impl Pool { + pub(crate) const fn new(cap: usize) -> Self { + Self { live: AtomicUsize::new(0), cap } + } + + /// Reserve up to `want` threads: as many as are free, none when none is. + fn reserve(&self, want: usize) -> usize { + let mut live = self.live.load(Ordering::SeqCst); + loop { + let n = want.min(self.cap.saturating_sub(live)); + if n == 0 { + return 0; + } + match self.live.compare_exchange(live, live + n, Ordering::SeqCst, Ordering::SeqCst) { + Ok(_) => return n, + Err(now) => live = now, + } + } + } + + fn release(&self, n: usize) { + self.live.fetch_sub(n, Ordering::SeqCst); + } + + /// The threads running now. + #[cfg(test)] + pub(crate) fn live(&self) -> usize { + self.live.load(Ordering::SeqCst) + } +} + +/// The threads of every search. +static POOL: Pool = Pool::new(MAX_LIVE); + +/// The walker threads for a search: the logical CPUs less one and less the renderer's threads, +/// from 2 to 8. +pub fn threads() -> usize { + let cpus = std::thread::available_parallelism().map_or(2, std::num::NonZero::get); + threads_for(cpus, usize::from(vectorcraft_render::default_threads())) +} + +/// [`threads`] on `cpus` logical CPUs with `render` renderer threads. +pub(crate) fn threads_for(cpus: usize, render: usize) -> usize { + cpus.saturating_sub(1 + render).clamp(2, MAX_THREADS) +} + +/// A running (or ended) search. Dropping it stops it. +pub struct Search { + shared: Arc, +} + +impl Search { + /// End the search ([`End::Stopped`]) unless it has ended. Walkers busy reading a folder or a + /// file stop after it. + pub fn stop(&self) { + let mut w = self.shared.lock(); + self.shared.finish(&mut w, End::Stopped); + } + + /// Where the search stands. Past its deadline it has ended ([`End::Time`]), even while a walker + /// is still waiting for a slow folder or file. + pub fn progress(&self) -> Progress { + let s = &self.shared; + let mut w = s.lock(); + if w.end.is_none() && Instant::now() >= s.deadline { + s.finish(&mut w, End::Time); + } + Progress { + end: w.end.clone(), + folder: plain(w.folder.clone()), + hits: w.hits.iter().map(|(path, items)| (plain(path.clone()), items.clone())).collect(), + folders: w.folders, + files: w.files, + read: s.read.load(Ordering::Relaxed).min(s.limits.files), + skipped: w.skipped, + unreadable: w.unreadable, + seconds: if w.end.is_some() { w.seconds } else { s.started.elapsed().as_secs_f64() }, + } + } +} + +impl Drop for Search { + fn drop(&mut self) { + self.stop(); + } +} + +/// Search `folder` and its subfolders on up to `threads` walker threads (at most 8, as many as the +/// process-wide cap leaves) for the `items` a `visitor` looks for, where `rules` allow (this +/// computer's when `None`). Returns at once; the folder is checked on a walker thread. +pub fn start( + folder: PathBuf, + rules: Option, + limits: Limits, + threads: usize, + items: usize, + visitor: Arc, +) -> Result { + start_in(&POOL, folder, rules, limits, threads, items, visitor) +} + +/// [`start`] with the threads of `pool`. +pub(crate) fn start_in( + pool: &'static Pool, + folder: PathBuf, + rules: Option, + limits: Limits, + threads: usize, + items: usize, + visitor: Arc, +) -> Result { + if cfg!(target_arch = "wasm32") { + return Err("searching a folder isn't available on the web".into()); + } + let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 }; + let n = pool.reserve(threads.clamp(1, MAX_THREADS)); + if n == 0 { + return Err("the search couldn't start: try again in a moment".into()); + } + let started = Instant::now(); + let shared = Arc::new(Shared { + stop: AtomicBool::new(false), + entries: AtomicU64::new(0), + read: AtomicU64::new(0), + started, + deadline: started + Duration::from_secs_f64(seconds), + limits, + items, + given: rules, + rules: OnceLock::new(), + visitor, + walk: Mutex::new(Walk { root: Some(folder), alive: n, found: vec![0; items], ..Walk::default() }), + wake: Condvar::new(), + pool, + }); + let mut spawned = 0; + for _ in 0..n { + let s = shared.clone(); + let thread = std::thread::Builder::new().name("vectorcraft-find".into()).spawn(move || { + let _alive = Alive(s.clone()); + // A bug in the walk ends this walker and the others go on; when every walker ends this + // way, the search fails ([`Alive`]). + let _ = crate::guard::catch_panic(|| walker(&s)); + }); + if thread.is_ok() { + spawned += 1; + } + } + if spawned < n { + pool.release(n - spawned); + let mut w = shared.lock(); + w.alive = w.alive.saturating_sub(n - spawned); + if spawned == 0 { + return Err("the search couldn't start: try again in a moment".into()); + } + if w.alive == 0 && w.end.is_none() { + shared.finish(&mut w, End::Failed(INTERNAL.into())); + } + } + Ok(Search { shared }) +} + +/// What a search's walkers share. +struct Shared { + /// Set once the search ended: walkers stop listing and reading. + stop: AtomicBool, + entries: AtomicU64, + read: AtomicU64, + started: Instant, + deadline: Instant, + limits: Limits, + items: usize, + /// The rules given to [`start`], else this computer's, read by the first walker. + given: Option, + rules: OnceLock, + visitor: Arc, + walk: Mutex, + /// Woken when folders are queued, a walker finishes a folder, or the search ends. + wake: Condvar, + pool: &'static Pool, +} + +/// The state walkers change under the lock. +#[derive(Default)] +struct Walk { + /// The picked folder, until a walker takes it to check it. + root: Option, + /// A walker is checking the picked folder. + preparing: bool, + folder: PathBuf, + /// Folders waiting to be listed, with their depth below the picked one. + stack: Vec<(PathBuf, u32)>, + /// Folders being listed (and their candidates read) now. + listing: usize, + /// Walker threads still running. + alive: usize, + end: Option, + seconds: f64, + hits: Vec<(PathBuf, Vec)>, + /// Files kept for each item. + found: Vec, + folders: u64, + files: u64, + skipped: u64, + unreadable: u64, + /// The home folder was queued from a system's root ([`Rules::home_below`]). + home_queued: bool, +} + +impl Shared { + fn lock(&self) -> MutexGuard<'_, Walk> { + self.walk.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// End the search as `end` unless it has ended, and wake every walker to see it. + fn finish(&self, w: &mut Walk, end: End) { + if w.end.is_none() { + w.end = Some(end); + w.seconds = self.started.elapsed().as_secs_f64(); + } + self.stop.store(true, Ordering::Relaxed); + self.wake.notify_all(); + } + + fn end_with(&self, end: End) { + let mut w = self.lock(); + self.finish(&mut w, end); + } + + /// Whether to stop listing and reading: the search ended or its time is up. + fn should_stop(&self) -> bool { + self.stop.load(Ordering::Relaxed) || Instant::now() >= self.deadline + } + + /// Whether the folders waiting to be listed and `more` folders found in the folder being listed + /// come to more than [`Limits::folders`], which ends the search ([`End::Limit`]). + fn too_many_folders(&self, more: usize) -> bool { + let mut w = self.lock(); + let over = w.stack.len().saturating_add(more) > self.limits.folders; + if over { + self.finish(&mut w, End::Limit); + } + over + } + + /// Check the picked folder, then queue it. + fn prepare(&self, root: &Path) { + let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); + let path = root.display(); + let checked = match std::fs::canonicalize(root) { + Err(e) => Err(format!("{path}: {e}")), + Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + Ok(folder) => match rules.refusal(&folder) { + None => Ok(folder), + Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), + Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + }, + }; + let mut w = self.lock(); + match checked { + Ok(folder) => { + w.folder = folder.clone(); + w.stack.push((folder, 0)); + } + Err(e) => self.finish(&mut w, End::Failed(e)), + } + } + + /// List one folder: queue its subfolders, then read the files [`Visitor::wants`] accepts. + fn list(&self, dir: &Path, depth: u32) { + let Some(rules) = self.rules.get() else { return }; + let (mut skipped, mut unreadable, mut files) = (0, 0, 0); + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + let mut w = self.lock(); + // The picked folder itself: the search can't run, and says why. + if depth == 0 { + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + } else { + w.unreadable += 1; + w.folders += 1; + } + return; + } + }; + let (mut folders, mut siblings, mut candidates) = (vec![], Siblings::default(), vec![]); + for (n, entry) in entries.enumerate() { + if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && (self.should_stop() || self.too_many_folders(folders.len())) { + break; + } + if self.entries.fetch_add(1, Ordering::Relaxed) >= self.limits.entries { + self.end_with(End::Limit); + break; + } + let Ok(entry) = entry else { + unreadable += 1; + break; + }; + let Ok(kind) = entry.file_type() else { + unreadable += 1; + continue; + }; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + // A name that isn't Unicode: its folder is left out, its file can't be wanted. + if kind.is_dir() { + skipped += 1; + } + continue; + }; + if kind.is_dir() || kind.is_symlink() { + siblings.note(name); + } + // Links are neither followed nor read. + if kind.is_dir() { + folders.push((entry, name.to_string())); + } else if kind.is_file() { + files += 1; + // At most one file more than the search reads is kept to be read. + if (candidates.len() as u64) <= self.limits.files && self.visitor.wants(name) && self.visitor.worth_reading(&entry.path()) { + if hidden_and_offline(&entry).1 { + // Its contents are in the cloud: counted as unreadable, never read. + unreadable += 1; + } else { + candidates.push(entry); + } + } + } + } + // The folder is told once for all its folders. + let listed = rules.listed(dir, siblings); + let mut queue = vec![]; + for (n, (entry, name)) in folders.into_iter().enumerate() { + if n as u64 % CHECK_EVERY == CHECK_EVERY - 1 && self.should_stop() { + break; + } + let path = entry.path(); + let (hidden, offline) = hidden_and_offline(&entry); + if depth >= self.limits.depth || name.starts_with('.') || hidden || offline || rules.never_in(&listed, &name) || rules.is_refused(&path) { + skipped += 1; + continue; + } + queue.push((path, depth + 1)); + } + let home = rules.home_below(&listed); + { + let mut w = self.lock(); + w.files += files; + w.skipped += skipped; + w.unreadable += unreadable; + if w.end.is_none() { + if w.stack.len() + queue.len() > self.limits.folders { + self.finish(&mut w, End::Limit); + } else { + w.stack.extend(queue); + if let Some(home) = home.filter(|_| !w.home_queued) { + w.home_queued = true; + w.stack.push((home, 1)); + } + self.wake.notify_all(); + } + } + } + let mut unreadable = 0; + for entry in candidates { + if self.should_stop() { + break; + } + if self.read.fetch_add(1, Ordering::Relaxed) >= self.limits.files { + self.end_with(End::Limit); + break; + } + let path = entry.path(); + match crate::guard::catch_panic(|| self.visitor.items(&path)) { + Ok(items) if !items.is_empty() => self.keep(path, items), + Ok(_) => {} + // A file that trips a bug: as one that can't be read, and the search goes on. + Err(_) => unreadable += 1, + } + } + let mut w = self.lock(); + w.folders += 1; + w.unreadable += unreadable; + } + + /// Keep a file the visitor gave `items` for, for those of its items that take more files, as + /// soon as it is read: a file read as the search ends is kept too. The search ends at the most + /// files it keeps, and once every item has a file. + fn keep(&self, path: PathBuf, mut items: Vec) { + let mut w = self.lock(); + items.sort_unstable(); + items.dedup(); + items.retain(|i| w.found.get(*i).is_some_and(|n| *n < self.limits.per_item)); + if items.is_empty() || w.hits.len() >= self.limits.hits { + return; + } + for i in &items { + if let Some(n) = w.found.get_mut(*i) { + *n += 1; + } + } + w.hits.push((path, items)); + if w.hits.len() >= self.limits.hits { + self.finish(&mut w, End::Limit); + } + if self.items > 0 && w.found.iter().all(|n| *n > 0) { + self.finish(&mut w, End::Found); + } + } +} + +/// What a walker does next. +enum Job { + Prepare(PathBuf), + List(PathBuf, u32), +} + +/// A walker thread: check the picked folder, or list the next folder, until the search ends. +fn walker(s: &Shared) { + loop { + let job = { + let mut w = s.lock(); + loop { + if w.end.is_some() { + return; + } + if Instant::now() >= s.deadline { + s.finish(&mut w, End::Time); + return; + } + if let Some(root) = w.root.take() { + w.preparing = true; + break Job::Prepare(root); + } + if let Some((dir, depth)) = w.stack.pop() { + w.listing += 1; + break Job::List(dir, depth); + } + if !w.preparing && w.listing == 0 { + s.finish(&mut w, End::Done); + return; + } + w = s.wake.wait_timeout(w, IDLE).map_or_else(|e| e.into_inner().0, |(w, _)| w); + } + }; + // The guard lowers `preparing` or `listing` when the job ends, on a bug too. + let _busy = Busy(s, matches!(job, Job::Prepare(_))); + let done = crate::guard::catch_panic(|| match &job { + Job::Prepare(root) => s.prepare(root), + Job::List(dir, depth) => s.list(dir, *depth), + }); + if done.is_err() { + let mut w = s.lock(); + match job { + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::List(..) => w.unreadable += 1, + } + } + } +} + +/// A walker's job in progress: preparing (`true`) or listing a folder. +struct Busy<'a>(&'a Shared, bool); + +impl Drop for Busy<'_> { + fn drop(&mut self) { + let mut w = self.0.lock(); + if self.1 { + w.preparing = false; + } else { + w.listing = w.listing.saturating_sub(1); + } + self.0.wake.notify_all(); + } +} + +/// A running walker thread, counted out of the search and the pool when it ends. +struct Alive(Arc); + +impl Drop for Alive { + fn drop(&mut self) { + let mut w = self.0.lock(); + w.alive = w.alive.saturating_sub(1); + if w.alive == 0 && w.end.is_none() { + self.0.finish(&mut w, End::Failed(INTERNAL.into())); + } + drop(w); + self.0.pool.release(1); + } +} + +/// Whether a folder or file is hidden by its flags (macOS `UF_HIDDEN`; Windows hidden or system), +/// and whether its contents are in the cloud rather than on the disk (macOS `SF_DATALESS`; Windows +/// offline or recalled on access). +#[cfg(target_os = "macos")] +fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) { + use std::os::macos::fs::MetadataExt; + const UF_HIDDEN: u32 = 0x8000; + const SF_DATALESS: u32 = 0x4000_0000; + entry.metadata().map_or((false, false), |m| (m.st_flags() & UF_HIDDEN != 0, m.st_flags() & SF_DATALESS != 0)) +} + +#[cfg(windows)] +fn hidden_and_offline(entry: &std::fs::DirEntry) -> (bool, bool) { + use std::os::windows::fs::MetadataExt; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + const OFFLINE: u32 = 0x1000; + const RECALL_ON_OPEN: u32 = 0x4_0000; + const RECALL_ON_DATA_ACCESS: u32 = 0x40_0000; + entry.metadata().map_or((false, false), |m| { + let a = m.file_attributes(); + (a & (HIDDEN | SYSTEM) != 0, a & (OFFLINE | RECALL_ON_OPEN | RECALL_ON_DATA_ACCESS) != 0) + }) +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn hidden_and_offline(_: &std::fs::DirEntry) -> (bool, bool) { + (false, false) +} diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs new file mode 100644 index 00000000..a5c86baf --- /dev/null +++ b/crates/engine/src/cmd/fontfiles.rs @@ -0,0 +1,515 @@ +//! The fonts a document uses that aren't available (`text.missingFonts`), the files that provide +//! them in a folder the user picks (`text.findFontFiles`, searched on separate threads: +//! [`super::findfiles`]), and copying the files the user chooses into VectorCraft's own Fonts +//! folder, which the font scan reads (`text.addFontFiles`). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_text::{FontDb, FontMatch, WantedFont, WantedFonts}; + +use super::findfiles::{self, Limits, Visitor}; +use super::*; +use crate::DocState; + +/// The most fonts listed, or looked for at once. +const MAX_FONTS: usize = 1_000; +/// The longest family or style name listed or given (bytes). +const MAX_NAME: usize = 256; +/// The CSS generic font families (CSS Fonts Module Level 4), which SVG and HTML files name in place +/// of a font and no font file provides; compared in any ASCII case. The list leaves out +/// `fangsong`, which is also the family name of a Chinese font (FangSong). +const GENERIC_FAMILIES: &[&str] = &[ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "math", + "emoji", +]; +/// The longest folder path given (bytes). +const MAX_PATH: usize = 4096; +/// The most files one `text.addFontFiles` copies: as many as a search keeps. +const MAX_ADD_FILES: usize = 1_000; +/// The largest font file copied, and the most bytes one `text.addFontFiles` copies. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const MAX_FONT_FILE: u64 = 256 << 20; +#[cfg(not(target_arch = "wasm32"))] +const MAX_ADD_BYTES: u64 = 1 << 30; +/// The highest number a copy's name gets (`Name 99.otf`). +#[cfg(not(target_arch = "wasm32"))] +const MAX_NUMBER: u32 = 99; +/// The extensions of the font files `text.addFontFiles` copies (in any case), besides suitcase +/// fonts. +#[cfg(not(target_arch = "wasm32"))] +const FONT_EXTENSIONS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"]; +/// Why a file wasn't copied. +const NOT_A_FILE: &str = "not a file"; +#[cfg(not(target_arch = "wasm32"))] +const NOT_A_FONT: &str = "not a font file"; +#[cfg(not(target_arch = "wasm32"))] +const TOO_LARGE: &str = "larger than 256 MB"; +#[cfg(not(target_arch = "wasm32"))] +const TOO_MUCH: &str = "the files come to more than 1 GB: add fewer at a time"; +#[cfg(not(target_arch = "wasm32"))] +const NO_NAME: &str = "every numbered name for it is taken in the Fonts folder"; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "text.missingFonts", + "Missing Fonts", + [], + None, + "{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)", + has_doc, + missing + ), + cmd!( + query "text.findFontFiles", + "Find Font Files", + [], + None, + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files and, on macOS, of suitcase fonts (files without an extension whose fonts are in their resource fork), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + always, + find + ), + cmd!( + query "text.addFontFiles", + "Add Font Files", + [], + None, + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: a file with the same contents is kept, and when another file has that name, the copy gets a number (Name 2.otf); then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + always, + add + ), + ] +} + +/// A font a document names that isn't available as named. +#[derive(Clone, Debug, PartialEq)] +pub struct MissingFont { + pub family: String, + pub style: String, + /// [`FontMatch::Missing`] or [`FontMatch::Style`]. + pub status: FontMatch, + /// The family and style of the face shown instead. + pub resolved: Option<(String, String)>, +} + +/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named +/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif` +/// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out. +pub fn missing_fonts(d: &Document) -> Vec { + let db = FontDb::global(); + let generic = |family: &str| GENERIC_FAMILIES.iter().any(|g| g.eq_ignore_ascii_case(family.trim())); + let mut used: Vec<(String, String)> = super::fonts::used_fonts(d).into_iter().map(|(family, style, _)| (family, style)).collect(); + // Sorted by family and style: the versions type names of one face are neighbors. + used.dedup(); + used.into_iter() + .filter(|(family, style)| !family.trim().is_empty() && !generic(family) && family.len() <= MAX_NAME && style.len() <= MAX_NAME) + .filter_map(|(family, style)| { + let resolved = db.resolve(&family, &style); + let status = resolved.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); + (status != FontMatch::Exact).then(|| MissingFont { + family, + style, + status, + resolved: resolved.map(|(f, _)| (f.family.clone(), f.style.clone())), + }) + }) + .take(MAX_FONTS) + .collect() +} + +/// `text.missingFonts`'s answer for the open document `st`, where searches go by `rules` (this +/// computer's when `None`). +pub fn missing_fonts_of(st: &DocState, rules: Option<&findfiles::Rules>) -> Value { + let fonts: Vec = missing_fonts(&st.doc) + .into_iter() + .map(|m| { + json!({ + "family": m.family, "style": m.style, "status": m.status.as_str(), + "resolved": m.resolved.map(|(family, style)| json!({ "family": family, "style": style })), + }) + }) + .collect(); + let mut out = json!({ "count": fonts.len(), "fonts": fonts }); + // File › Package puts the fonts in a `Fonts` folder next to the packaged document. The folder + // is made absolute against the working folder when the document's path is relative. It is + // offered when a search may start there, as far as the rules tell without listing a folder. + if let Some(dir) = st.path.as_deref().and_then(|p| Path::new(p).parent()).map(|d| d.join("Fonts")).filter(|d| d.is_dir()) { + let dir = super::fileio::absolute_path(&dir.to_string_lossy()); + let system; + let rules = match rules { + Some(rules) => rules, + None => { + system = findfiles::Rules::system(); + &system + } + }; + if std::fs::canonicalize(&dir).is_ok_and(|c| rules.refusal_unlisted(&c).is_none()) { + out["fontsNextToDocument"] = json!(dir); + } + } + out +} + +fn missing(s: &mut Session, _: &Value) -> Result { + Ok(missing_fonts_of(s.doc()?, s.search_rules.as_ref())) +} + +/// A font a search looks for. +#[derive(Clone, Debug)] +pub(crate) struct Sought { + pub(crate) family: String, + pub(crate) style: String, + pub(crate) status: FontMatch, +} + +/// The session's last search for font files (`text.findFontFiles`). +pub(crate) struct FontSearch { + /// The search's number in the session; [`stop`] takes it. + pub(crate) id: u64, + /// The folder as given. + pub(crate) folder: String, + pub(crate) fonts: Vec, + pub(crate) search: findfiles::Search, +} + +/// The files a search for fonts reads: font files, by their names ([`WantedFonts`]). +struct FontFiles(WantedFonts); + +impl Visitor for FontFiles { + fn wants(&self, name: &str) -> bool { + let path = Path::new(name); + // A suitcase font (macOS) often has no extension. + vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none() + } + fn worth_reading(&self, path: &Path) -> bool { + // A file without an extension is read only when it is a suitcase font. + path.extension().is_some() || vectorcraft_text::is_suitcase(path) + } + fn items(&self, path: &Path) -> Vec { + self.0.provided_by(path) + } +} + +/// `[{family, style?}]`: 1 to 1000 fonts with a family of 1 to 256 bytes and a style of at most 256 +/// (Regular when left out). +fn font_list(v: &Value) -> Option> { + let list = v.as_array().filter(|a| (1..=MAX_FONTS).contains(&a.len()))?; + list.iter() + .map(|f| { + let family = f.get("family")?.as_str().filter(|s| !s.trim().is_empty() && s.len() <= MAX_NAME)?; + let style = match f.get("style") { + None | Some(Value::Null) => "Regular", + Some(s) => s.as_str().filter(|s| s.len() <= MAX_NAME)?, + }; + Some((family.to_string(), style.to_string())) + }) + .collect() +} + +fn find(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.findFontFiles"; + static NEXT: AtomicU64 = AtomicU64::new(1); + if bool_or(p, "stop", false) { + if let Some(f) = &s.font_search { + f.search.stop(); + } + return Ok(state(s)); + } + let Some(folder) = p.get("folder").filter(|f| !f.is_null()) else { return Ok(state(s)) }; + let given = match p.get("fonts") { + None | Some(Value::Null) => None, + Some(v) => Some(font_list(v).ok_or_else(|| bad(C, "each of `fonts` is {family, style?}; 1 to 1000 of them"))?), + }; + let seconds = p.get("maxSeconds").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(60.0).clamp(1.0, 600.0); + // Only checked here: the folder is read on a walker thread. + let folder = folder + .as_str() + .filter(|f| !f.is_empty() && f.len() <= MAX_PATH && !f.contains(['\0', '\u{FFFD}']) && Path::new(f).is_absolute()) + .ok_or_else(|| bad(C, "`folder` must be an absolute folder path"))? + .to_string(); + let fonts = match given { + Some(fonts) => fonts, + None => missing_fonts(&s.active().ok_or_else(|| bad(C, "give `fonts`, or open a document"))?.doc) + .into_iter() + .map(|m| (m.family, m.style)) + .collect(), + }; + let db = FontDb::global(); + let (mut sought, mut wanted) = (vec![], vec![]); + for (family, style) in fonts { + let (status, installed) = match db.resolve(&family, &style) { + Some((_, FontMatch::Exact)) => continue, + Some((f, FontMatch::Style)) => (FontMatch::Style, Some(f.family.clone())), + _ => (FontMatch::Missing, None), + }; + wanted.push(WantedFont { family: family.clone(), style: style.clone(), installed }); + sought.push(Sought { family, style, status }); + } + if sought.is_empty() { + return Err(bad(C, "no fonts to look for: they are all available")); + } + // A new search stops the last one. + s.font_search = None; + let threads = s.search_threads.unwrap_or_else(findfiles::threads); + let limits = Limits { seconds, ..Limits::default() }; + let visitor = Arc::new(FontFiles(WantedFonts::new(&wanted))); + let search = + findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, sought.len(), visitor).map_err(EngineError::Other)?; + s.font_search = Some(FontSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, fonts: sought, search }); + Ok(state(s)) +} + +/// Where the session's last search stands (`text.findFontFiles` without `folder`). +fn state(s: &Session) -> Value { + let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; + let p = f.search.progress(); + let mut files: Vec> = vec![vec![]; f.fonts.len()]; + for (path, items) in &p.hits { + for i in items { + if let Some(v) = files.get_mut(*i) { + v.push(path.to_string_lossy().into_owned()); + } + } + } + let fonts: Vec = f + .fonts + .iter() + .zip(files) + .map(|(font, mut files)| { + files.sort(); + json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) + }) + .collect(); + let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; + let mut out = json!({ + "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, + "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, + }); + if let Some(why) = p.stopped() { + out["stopped"] = json!(why); + } + if let Some(e) = p.error() { + out["error"] = json!(e); + } + out +} + +/// Stop the session's search when it is the one numbered `id` (`text.findFontFiles`'s `id`): a +/// newer search, started by someone else, goes on. +pub fn stop(s: &mut Session, id: u64) { + if let Some(f) = s.font_search.as_ref().filter(|f| f.id == id) { + f.search.stop(); + } +} + +/// What copying font files into a folder did: each file copied (from, to), kept because the same +/// file is there already (from, the file there), or skipped (file, why). +#[derive(Debug, Default)] +pub(crate) struct CopyReport { + pub(crate) copied: Vec<(PathBuf, PathBuf)>, + pub(crate) kept: Vec<(PathBuf, PathBuf)>, + pub(crate) skipped: Vec<(PathBuf, String)>, +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.addFontFiles"; + let mut files: Vec = p + .get("files") + .and_then(Value::as_array) + .filter(|a| (1..=MAX_ADD_FILES).contains(&a.len())) + .and_then(|a| a.iter().map(|f| f.as_str().map(PathBuf::from)).collect::>>()) + .ok_or_else(|| bad(C, "`files` must be 1 to 1000 paths of font files the last text.findFontFiles search found"))?; + files.sort(); + files.dedup(); + let dir = vectorcraft_text::app_font_dir() + .ok_or_else(|| EngineError::Other("VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it".into()))?; + let found: Vec = + s.font_search.as_ref().map(|f| f.search.progress().hits.into_iter().map(|(path, _)| path).collect()).unwrap_or_default(); + if let Some(f) = files.iter().find(|f| !found.contains(f)) { + return Err(bad(C, format!("`{}` is not a font file the last text.findFontFiles search found", f.display()))); + } + super::fileio::create_dir(&dir.to_string_lossy())?; + let report = copy_into(&files, dir); + let pairs = |v: &[(PathBuf, PathBuf)]| { + v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::>() + }; + let mut out = json!({ + "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::>(), + }); + if !report.copied.is_empty() || !report.kept.is_empty() { + // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. + let r = super::fonts::rescan(s, &Value::Null)?; + out["families"] = r["families"].clone(); + out["faces"] = r["faces"].clone(); + } + Ok(out) +} + +/// Where [`copy_one`] put a file: copied to, or kept as, the file in the folder. +#[cfg(not(target_arch = "wasm32"))] +enum Placed { + Copied(PathBuf), + Kept(PathBuf), +} + +/// Copy `files` into `dir`, never replacing a file there: a file with the same contents is kept, +/// and when another file has the name, the copy gets the first free name from `Name 2.otf` to +/// `Name 99.otf`. At most [`MAX_FONT_FILE`] bytes a file and 1 GB in all. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn copy_into(files: &[PathBuf], dir: &Path) -> CopyReport { + let mut report = CopyReport::default(); + let mut total = 0; + for from in files { + match copy_one(from, dir, &mut total) { + Ok(Placed::Copied(to)) => report.copied.push((from.clone(), to)), + Ok(Placed::Kept(to)) => report.kept.push((from.clone(), to)), + Err(why) => report.skipped.push((from.clone(), why)), + } + } + report +} + +/// The web has no file system: nothing is copied. +#[cfg(target_arch = "wasm32")] +pub(crate) fn copy_into(files: &[PathBuf], _: &Path) -> CopyReport { + CopyReport { skipped: files.iter().map(|f| (f.clone(), NOT_A_FILE.to_string())).collect(), ..CopyReport::default() } +} + +#[cfg(not(target_arch = "wasm32"))] +fn copy_one(from: &Path, dir: &Path, total: &mut u64) -> std::result::Result { + use std::io::ErrorKind; + let meta = std::fs::symlink_metadata(from).map_err(|e| e.to_string())?; + if !meta.is_file() { + return Err(NOT_A_FILE.into()); + } + let font = from.extension().and_then(|e| e.to_str()).is_some_and(|e| FONT_EXTENSIONS.iter().any(|x| e.eq_ignore_ascii_case(x))); + // A suitcase font's fonts are in its resource fork, which is copied with it, whatever its name. + let fork = match (vectorcraft_text::is_suitcase(from), font) { + (true, _) => Some(read_fork(from)?), + (false, true) => None, + (false, false) => return Err(NOT_A_FONT.into()), + }; + let len = fork.as_ref().map_or(meta.len(), |f| f.len() as u64); + if len > MAX_FONT_FILE { + return Err(TOO_LARGE.into()); + } + if total.saturating_add(len) > MAX_ADD_BYTES { + return Err(TOO_MUCH.into()); + } + let (Some(name), Some(stem)) = (from.file_name(), from.file_stem()) else { return Err(NOT_A_FILE.into()) }; + let ext = from.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default(); + for n in 1..=MAX_NUMBER { + let to = if n == 1 { dir.join(name) } else { dir.join(format!("{} {n}{ext}", stem.to_string_lossy())) }; + match std::fs::symlink_metadata(&to) { + Err(e) if e.kind() == ErrorKind::NotFound => match create_copy(from, &to, fork.as_deref()) { + Ok(()) => { + *total += len; + return Ok(Placed::Copied(to)); + } + // Taken meanwhile: the next name. + Err(e) if e.kind() == ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.to_string()), + }, + Ok(m) if m.is_file() && same_file(from, &to, m.len(), len, fork.as_deref()) => return Ok(Placed::Kept(to)), + _ => {} + } + } + Err(NO_NAME.into()) +} + +/// Create `to` as a copy of the font file `from`, or for a suitcase font, as an empty file whose +/// resource fork is `fork`; never in place of a file. +#[cfg(not(target_arch = "wasm32"))] +fn create_copy(from: &Path, to: &Path, fork: Option<&[u8]>) -> std::io::Result<()> { + let Some(fork) = fork else { return vectorcraft_format::write_new_with(to, |f| copy_capped(from, f)) }; + vectorcraft_format::write_new_with(to, |_| Ok(()))?; + std::fs::write(fork_path(to), fork).inspect_err(|_| { + // Best effort: the empty file just made is all there is to clean up. + let _ = std::fs::remove_file(to); + }) +} + +/// Whether the file `to` (`to_len` bytes) holds what the copy of `from` (`len` bytes, or the +/// resource fork `fork` of a suitcase font) would. +#[cfg(not(target_arch = "wasm32"))] +fn same_file(from: &Path, to: &Path, to_len: u64, len: u64, fork: Option<&[u8]>) -> bool { + match fork { + None => to_len == len && same_contents(from, to), + Some(fork) => to_len == 0 && read_fork(to).is_ok_and(|f| f == fork), + } +} + +/// The resource fork of the file at `path` (where macOS keeps a suitcase font's fonts), at most +/// [`MAX_FONT_FILE`] bytes. +#[cfg(not(target_arch = "wasm32"))] +fn read_fork(path: &Path) -> std::result::Result, String> { + use std::io::Read; + let mut fork = vec![]; + std::fs::File::open(fork_path(path)).and_then(|f| f.take(MAX_FONT_FILE + 1).read_to_end(&mut fork)).map_err(|e| e.to_string())?; + if fork.len() as u64 > MAX_FONT_FILE { + return Err(TOO_LARGE.into()); + } + Ok(fork) +} + +/// Where macOS shows the resource fork of the file at `path`. +#[cfg(not(target_arch = "wasm32"))] +fn fork_path(path: &Path) -> PathBuf { + path.join("..namedfork/rsrc") +} + +/// Copy the file `from` into `to`, failing past [`MAX_FONT_FILE`] bytes (a file that grew since +/// it was measured). +#[cfg(not(target_arch = "wasm32"))] +fn copy_capped(from: &Path, to: &mut std::fs::File) -> std::io::Result<()> { + use std::io::Read; + let copied = std::io::copy(&mut std::fs::File::open(from)?.take(MAX_FONT_FILE + 1), to)?; + if copied > MAX_FONT_FILE { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, TOO_LARGE)); + } + Ok(()) +} + +/// Whether the files `a` and `b` hold the same bytes (read 64 KB at a time). +#[cfg(not(target_arch = "wasm32"))] +fn same_contents(a: &Path, b: &Path) -> bool { + let (Ok(mut a), Ok(mut b)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false }; + let (mut x, mut y) = (vec![0; 1 << 16], vec![0; 1 << 16]); + loop { + match (fill(&mut a, &mut x), fill(&mut b, &mut y)) { + (Ok(0), Ok(0)) => return true, + (Ok(n), Ok(m)) if n == m && x.get(..n) == y.get(..m) => {} + _ => return false, + } + } +} + +/// Read from `f` until `buf` is full or the file ends: the bytes read. +#[cfg(not(target_arch = "wasm32"))] +fn fill(f: &mut std::fs::File, buf: &mut [u8]) -> std::io::Result { + use std::io::Read; + let mut n = 0; + while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) { + match f.read(rest)? { + 0 => break, + k => n += k, + } + } + Ok(n) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 14b350a0..89c0279c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -22,7 +22,9 @@ mod effectcmd; pub mod expand; pub(crate) mod fileinfo; pub mod fileio; +pub mod findfiles; pub mod flatten; +pub mod fontfiles; mod fonts; pub(crate) mod freeform; pub(crate) mod gradient; @@ -223,6 +225,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); + v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); v.extend(textwrap::specs()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 8e3e8b66..016c7c85 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -50,6 +50,17 @@ fn font_ext(bytes: &[u8]) -> &'static str { } } +/// The name a font's file gets in the package's `Fonts` folder: the name of the file at `path`, +/// with the extension the font's `data` calls for ([`font_ext`]) when that name has no font +/// extension; without a file, `family-style` with that extension. +fn packaged_name(path: Option<&Path>, family: &str, style: &str, data: &[u8]) -> String { + match path.and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()) { + Some(name) if vectorcraft_text::is_font_file(Path::new(&name)) => name, + Some(name) => format!("{name}.{}", font_ext(data)), + None => format!("{family}-{style}.{}", font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), + } +} + /// The options of a package. struct Options { copy_links: bool, @@ -123,10 +134,7 @@ fn package(s: &mut Session, p: &Value) -> Result { let reason = match resolved { Some((f, _)) if f.embeddable() => { let data = f.file_data(); - let file = f.path().and_then(Path::file_name).map_or_else( - || format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""), - |n| n.to_string_lossy().into_owned(), - ); + let file = packaged_name(f.path(), &f.family, &f.style, data); // A collection, or a style shown in another face's file, is copied once. if files.insert(file.to_lowercase()) { entries.push((format!("Fonts/{file}"), data.to_vec())); @@ -258,3 +266,21 @@ pub(crate) fn zip(files: &[(String, &[u8])]) -> Result> { out.extend_from_slice(&0u16.to_le_bytes()); Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A font read from a file without a font extension (one a font manager keeps, for example) is + /// packaged with the extension its data calls for. + #[test] + fn packaged_fonts_have_a_font_extension() { + let (otf, ttc, ttf) = (b"OTTO....".as_slice(), b"ttcf....".as_slice(), b"\0\x01\0\0....".as_slice()); + let name = |path: Option<&str>, data: &[u8]| packaged_name(path.map(Path::new), "Some Family", "Bold", data); + assert_eq!(name(Some("/fonts/A8F3C2"), otf), "A8F3C2.otf"); + assert_eq!(name(Some("/fonts/Shared Fonts.dat"), ttc), "Shared Fonts.dat.ttc"); + assert_eq!(name(Some("/fonts/Kept.TTF"), otf), "Kept.TTF"); + assert_eq!(name(Some("/fonts/Kept.otc"), ttc), "Kept.otc"); + assert_eq!(name(None, ttf), "SomeFamily-Bold.ttf"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b25a07b6..b4912f72 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -861,6 +861,15 @@ pub struct Session { pub(crate) plane_widget_press: bool, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, + /// The last search for the files of missing fonts (`text.findFontFiles`), until another + /// starts; dropping it stops it. + pub(crate) font_search: Option, + /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). + /// Tests set it. + pub search_rules: Option, + /// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set + /// it. + pub search_threads: Option, } impl Default for Session { @@ -913,6 +922,9 @@ impl Session { liquify_stroke: None, plane_widget_press: false, ruler_guide: None, + font_search: None, + search_rules: None, + search_threads: None, } } @@ -1459,6 +1471,8 @@ mod tests_flatten; #[cfg(test)] mod tests_focal; #[cfg(test)] +mod tests_fontfiles; +#[cfg(test)] mod tests_fontlist; #[cfg(test)] mod tests_freeform; diff --git a/crates/engine/src/tests_fontfiles.rs b/crates/engine/src/tests_fontfiles.rs new file mode 100644 index 00000000..3aa1c357 --- /dev/null +++ b/crates/engine/src/tests_fontfiles.rs @@ -0,0 +1,401 @@ +//! Missing fonts: listed (`text.missingFonts`), their files found in a folder on threads of their +//! own (`text.findFontFiles`), and copied into a folder without replacing a file there. These tests +//! never set VectorCraft's Fonts folder and never add a font to the process's font database: +//! `tests/font_folder.rs` does both, in a process of its own. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_text::FontMatch; + +use crate::cmd::findfiles::{self, Limits, Rules, Visitor}; +use crate::cmd::fontfiles::{self, FontSearch, MAX_FONT_FILE, Sought, copy_into}; +use crate::{EngineError, Session}; + +/// A fresh temporary folder (canonical, as a search reports it). +fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("fontfiles-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + findfiles::plain(std::fs::canonicalize(&dir).unwrap()) +} + +/// A session whose folder searches may go anywhere in `root`, on two threads. +fn session_for(root: &Path) -> Session { + let mut s = Session::new(); + s.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + s.search_threads = Some(2); + s +} + +/// `text.findFontFiles {}` once the search ended (10 seconds at most). +fn finished(s: &mut Session) -> Value { + let until = Instant::now() + Duration::from_secs(10); + loop { + let r = s.execute("text.findFontFiles", &json!({})).unwrap(); + if r["state"] != "searching" || Instant::now() > until { + return r; + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// New point type in `font` and `style` → its id. +fn text(s: &mut Session, y: f64, font: &str, style: &str) -> u64 { + s.execute("text.create", &json!({"x": 10, "y": y, "text": "Ab", "font": font, "style": style})).unwrap()["id"].as_u64().unwrap() +} + +fn bundled(file: &str) -> Vec { + std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap() +} + +#[test] +fn missing_fonts_are_listed_symbols_included() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + text(&mut s, 20.0, "No Such Font Family", "Regular"); + // Type naming another version of a missing font is listed with it. + let other = vectorcraft_doc::NodeId(text(&mut s, 40.0, "No Such Font Family", "Regular")); + s.edit("version", |d, _| { + if let vectorcraft_doc::NodeKind::Text(t) = &mut d.node_mut(other).unwrap().kind { + t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); + } + Ok(()) + }) + .unwrap(); + // A style no Source Sans 3 has, bundled or installed. + text(&mut s, 60.0, "Source Sans 3", "Black Wide"); + text(&mut s, 100.0, "Source Sans 3", "Semibold"); + let id = text(&mut s, 140.0, "Symbol Only Family", "Bold"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("symbol.new", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + // CSS generic families, which no font file provides, are left out. + text(&mut s, 180.0, "sans-serif", "Regular"); + text(&mut s, 220.0, "SERIF", "Bold"); + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + let rows: Vec<(&str, &str, &str)> = r["fonts"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["family"].as_str().unwrap(), f["style"].as_str().unwrap(), f["status"].as_str().unwrap())) + .collect(); + assert_eq!( + rows, + [("No Such Font Family", "Regular", "missing"), ("Source Sans 3", "Black Wide", "substitute"), ("Symbol Only Family", "Bold", "missing")] + ); + assert_eq!((r["count"].as_u64(), r["fonts"][1]["resolved"]["family"].as_str()), (Some(3), Some("Source Sans 3"))); + assert!(r.get("fontsNextToDocument").is_none(), "an unsaved document has no folder"); + let listed = s.execute("text.fonts", &json!({})).unwrap(); + assert!(!listed.as_array().unwrap().iter().any(|f| f["family"] == "Symbol Only Family"), "Find Font lists the layers' type only"); + assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif" && f["status"] == "missing"), "{listed}"); + assert!(matches!(Session::new().execute("text.missingFonts", &json!({})), Err(EngineError::Disabled(..)))); +} + +/// A font whose name is longer than 256 bytes is left out of the list, and a search for the fonts +/// listed finds the other fonts' files. +#[test] +fn an_overlong_font_name_leaves_the_other_fonts_searchable() { + let root = folder("long-name"); + std::fs::write(root.join("Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); + let mut s = session_for(&root); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + text(&mut s, 20.0, &"L".repeat(300), "Regular"); + text(&mut s, 60.0, "Findme Sans 3", "Regular"); + let listed = s.execute("text.missingFonts", &json!({})).unwrap(); + let fonts: Vec = listed["fonts"].as_array().unwrap().iter().map(|f| json!({"family": f["family"], "style": f["style"]})).collect(); + assert_eq!(fonts, [json!({"family": "Findme Sans 3", "style": "Regular"})]); + s.execute("text.findFontFiles", &json!({"folder": root, "fonts": fonts})).unwrap(); + assert_eq!(finished(&mut s)["fonts"][0]["files"], json!([root.join("Findme.otf")])); +} + +/// The Fonts folder next to a document is offered only where a search may start: not inside other +/// apps' folders, such as the attachments a mail app keeps in the home folder's Library. +#[test] +fn the_fonts_folder_next_to_a_document_in_another_apps_folder_isnt_offered() { + let root = folder("next-to"); + let home = root.join("Users/me"); + let mut s = Session::new(); + s.search_rules = Some(Rules { home: Some(home.clone()), ..Rules::default() }); + for (dir, offered) in [("Library/Mail Downloads/pkg", false), ("Documents/pkg", true)] { + std::fs::create_dir_all(home.join(dir).join("Fonts")).unwrap(); + let mut doc = vectorcraft_doc::Document::new(100.0, 100.0); + doc.title = "doc".into(); + s.add_document(doc, Some(home.join(dir).join("doc.vectorcraft").to_string_lossy().into_owned())); + text(&mut s, 20.0, "No Such Font Family", "Regular"); + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + assert_eq!(r.get("fontsNextToDocument").is_some(), offered, "{dir}: {r}"); + } +} + +/// A document opened by a relative path gives the Fonts folder next to it as an absolute path, +/// which `text.findFontFiles` takes. +#[cfg(unix)] +#[test] +fn the_fonts_folder_next_to_a_document_is_an_absolute_path() { + let root = folder("relative"); + std::fs::create_dir_all(root.join("pkg/Fonts")).unwrap(); + let cwd = std::env::current_dir().unwrap(); + let up = "../".repeat(cwd.components().count().saturating_sub(1)); + let relative = format!("{up}{}", root.join("pkg/doc.vectorcraft").strip_prefix("/").unwrap().display()); + assert!(Path::new(&relative).is_relative()); + let mut s = session_for(&root); + let mut doc = vectorcraft_doc::Document::new(100.0, 100.0); + doc.title = "doc".into(); + s.add_document(doc, Some(relative)); + text(&mut s, 20.0, "No Such Font Family", "Regular"); + let next = s.execute("text.missingFonts", &json!({})).unwrap()["fontsNextToDocument"].as_str().unwrap().to_string(); + assert!(Path::new(&next).is_absolute(), "{next}"); + assert_eq!(std::fs::canonicalize(&next).unwrap(), root.join("pkg/Fonts")); + assert!(s.execute("text.findFontFiles", &json!({"folder": next})).is_ok()); + assert_ne!(finished(&mut s)["state"], "failed"); +} + +#[test] +fn a_search_finds_a_missing_fonts_file_by_its_names() { + let root = folder("find"); + std::fs::create_dir_all(root.join("x/y")).unwrap(); + std::fs::write(root.join("x/y/Findme.otf"), vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); + std::fs::write(root.join("other.ttf"), bundled("SourceSerif4-Regular.ttf")).unwrap(); + std::fs::write(root.join("readme.txt"), "Findme Sans 3").unwrap(); + let mut s = session_for(&root); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + text(&mut s, 20.0, "Findme Sans 3", "Regular"); + // Never found: the search reads the whole folder. + text(&mut s, 60.0, "No Such Font Family", "Regular"); + let r = s.execute("text.findFontFiles", &json!({"folder": root})).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + let id = r["id"].clone(); + let r = finished(&mut s); + assert_eq!((&r["state"], &r["id"], r.get("stopped")), (&json!("done"), &id, None), "{r}"); + let found = root.join("x/y/Findme.otf").to_string_lossy().into_owned(); + let findme = r["fonts"].as_array().unwrap().iter().find(|f| f["family"] == "Findme Sans 3").cloned().unwrap(); + assert_eq!((findme["status"].as_str(), findme["files"].clone()), (Some("missing"), json!([found]))); + assert_eq!(r["searched"]["fontFiles"], 2, "the font files are read, not the text file: {r}"); + assert_eq!(r["folder"], json!(root)); + // Fonts given by name: the search ends once each has a file. + let r = s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Findme Sans 3"}]})).unwrap(); + assert_ne!(r["id"], id, "a new search"); + let r = finished(&mut s); + assert_eq!((&r["state"], &r["fonts"][0]["files"]), (&json!("done"), &json!([found])), "{r}"); + // Fonts that are available aren't looked for. + let r = s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Source Sans 3"}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); + // Stopped by a new search, or by `stop`. + s.execute("text.findFontFiles", &json!({"folder": root})).unwrap(); + let r = s.execute("text.findFontFiles", &json!({"stop": true})).unwrap(); + assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); +} + +#[test] +fn junk_search_params_never_fail_internally() { + let root = folder("junk"); + let mut s = session_for(&root); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + text(&mut s, 20.0, "No Such Font Family", "Regular"); + let internal = |r: &crate::Result| matches!(r, Err(EngineError::Internal { .. })); + let mut cases = vec![]; + for v in vectorcraft_testkit::strategies::junk_values() { + cases.extend([ + json!({"folder": root, "maxSeconds": v}), + json!({"folder": root, "fonts": v}), + json!({"folder": root, "stop": v}), + json!({"folder": v}), + json!({"folder": root, "fonts": [v]}), + json!({"folder": root, "fonts": [{"family": v}]}), + json!({"folder": root, "fonts": [{"family": "No Such Font Family", "style": v}]}), + ]); + assert!(s.execute("text.addFontFiles", &json!({"files": v})).is_err()); + assert!(s.execute("text.addFontFiles", &json!({"files": [v]})).is_err()); + } + let long = "x".repeat(257); + let many: Vec = (0..1001).map(|i| json!({"family": format!("No Such Family {i}")})).collect(); + cases.extend([ + json!({"folder": root, "fonts": [{"family": long}]}), + json!({"folder": root, "fonts": [{"family": "x", "style": long}]}), + json!({"folder": root, "fonts": [{"family": "a\u{0}b"}]}), + json!({"folder": root, "fonts": many}), + json!({"folder": root, "fonts": many[..1000]}), + json!({"folder": "/a\u{0}b"}), + json!({"folder": "relative/folder"}), + json!({"folder": "/\u{FFFD}"}), + json!({"folder": format!("/{}", "x".repeat(4096))}), + json!({"folder": root.join("No Such Folder")}), + ]); + let mut started = 0; + for p in &cases { + let r = s.execute("text.findFontFiles", p); + assert!(!internal(&r), "{p}: {r:?}"); + if r.is_ok() && p.get("folder").is_some_and(|f| f.is_string()) { + started += 1; + assert_ne!(finished(&mut s)["state"], "searching", "{p}"); + } + } + assert!(started > 40, "only {started} searches started"); + // After a finished search, files it didn't find aren't copied. + for v in [json!(["/no/such.ttf"]), json!([root.join("readme.ttf")])] { + assert!(s.execute("text.addFontFiles", &json!({"files": v})).is_err()); + } +} + +/// Reads every file, and waits on reading one until its channel sends or closes. +struct Blocked(Mutex>); + +impl Visitor for Blocked { + fn wants(&self, _: &str) -> bool { + true + } + fn items(&self, _: &Path) -> Vec { + let _ = self.0.lock().unwrap().recv(); + vec![] + } +} + +#[test] +fn stop_ends_only_the_search_it_names() { + let root = folder("stop"); + std::fs::write(root.join("a.ttf"), b"font").unwrap(); + let mut s = session_for(&root); + let (tx, rx) = mpsc::channel(); + let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 1, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); + let fonts = vec![Sought { family: "A".into(), style: "Regular".into(), status: FontMatch::Missing }]; + s.font_search = Some(FontSearch { id: 7, folder: root.to_string_lossy().into_owned(), fonts, search }); + let until = Instant::now() + Duration::from_secs(10); + while s.execute("text.findFontFiles", &json!({})).unwrap()["searched"]["fontFiles"] != 1 && Instant::now() < until { + std::thread::sleep(Duration::from_millis(2)); + } + fontfiles::stop(&mut s, 8); + assert_eq!(s.execute("text.findFontFiles", &json!({})).unwrap()["state"], "searching"); + fontfiles::stop(&mut s, 7); + let r = s.execute("text.findFontFiles", &json!({})).unwrap(); + assert_eq!((r["state"].as_str(), r["stopped"].as_str(), r["id"].as_u64()), (Some("stopped"), Some("stop"), Some(7))); + drop(tx); +} + +#[test] +fn adding_fonts_needs_vectorcraft_s_fonts_folder() { + let mut s = Session::new(); + let r = s.execute("text.addFontFiles", &json!({"files": ["/fonts/a.ttf"]})); + assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("isn't set here")), "{r:?}"); + // As many files as a search keeps (one for each of 1000 fonts) can be added at once. + let files = |n: usize| json!({"files": (0..n).map(|i| format!("/fonts/{i}.ttf")).collect::>()}); + let r = s.execute("text.addFontFiles", &files(1000)); + assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("isn't set here")), "{r:?}"); + assert!(matches!(s.execute("text.addFontFiles", &files(1001)), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn copying_never_replaces_a_file() { + let root = folder("copy"); + let (src, dest) = (root.join("src"), root.join("dest")); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::write(src.join("a.otf"), b"new font").unwrap(); + std::fs::write(dest.join("a.otf"), b"keep").unwrap(); + let r = copy_into(&[src.join("a.otf")], &dest); + assert_eq!(r.copied, [(src.join("a.otf"), dest.join("a 2.otf"))]); + assert_eq!((std::fs::read(dest.join("a.otf")).unwrap(), std::fs::read(dest.join("a 2.otf")).unwrap()), (b"keep".to_vec(), b"new font".to_vec())); + // The same file again: kept, not copied a third time. + let r = copy_into(&[src.join("a.otf")], &dest); + assert_eq!((r.copied.len(), r.kept.clone()), (0, vec![(src.join("a.otf"), dest.join("a 2.otf"))])); + assert!(!dest.join("a 3.otf").exists()); + // A link of that name, even one to nothing, is neither replaced nor written through. + #[cfg(unix)] + { + std::fs::write(src.join("b.otf"), b"b").unwrap(); + std::os::unix::fs::symlink(root.join("nothing.otf"), dest.join("b.otf")).unwrap(); + let r = copy_into(&[src.join("b.otf")], &dest); + assert_eq!(r.copied, [(src.join("b.otf"), dest.join("b 2.otf"))]); + assert!(!root.join("nothing.otf").exists()); + } + // A name taken in another case: on a file system that ignores case, the copy gets a number. + std::fs::write(dest.join("C.OTF"), b"other").unwrap(); + std::fs::write(src.join("c.otf"), b"c").unwrap(); + let ignores_case = dest.join("c.otf").exists(); + let r = copy_into(&[src.join("c.otf")], &dest); + let to = dest.join(if ignores_case { "c 2.otf" } else { "c.otf" }); + assert_eq!((r.copied, std::fs::read(dest.join("C.OTF")).unwrap()), (vec![(src.join("c.otf"), to)], b"other".to_vec())); + // Every numbered name taken. + std::fs::write(src.join("d.otf"), b"d").unwrap(); + std::fs::write(dest.join("d.otf"), b"other").unwrap(); + for n in 2..=99 { + std::fs::write(dest.join(format!("d {n}.otf")), b"other").unwrap(); + } + let r = copy_into(&[src.join("d.otf")], &dest); + assert_eq!(r.skipped, [(src.join("d.otf"), "every numbered name for it is taken in the Fonts folder".to_string())]); + // Too large (a sparse file), a folder, and a file that isn't there. + let huge = std::fs::File::create(src.join("huge.otf")).unwrap(); + huge.set_len(MAX_FONT_FILE + 1).unwrap(); + std::fs::create_dir_all(src.join("folder.otf")).unwrap(); + let r = copy_into(&[src.join("huge.otf"), src.join("folder.otf"), src.join("none.otf")], &dest); + let reasons: Vec<&str> = r.skipped.iter().map(|(_, why)| why.as_str()).collect(); + assert_eq!(reasons[..2], ["larger than 256 MB", "not a file"]); + assert!(r.copied.is_empty() && reasons.len() == 3, "{r:?}"); + assert!(!dest.join("huge.otf").exists() && !dest.join("folder.otf").exists()); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn only_font_files_are_copied() { + let root = folder("allowed"); + let (src, dest) = (root.join("src"), root.join("dest")); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + let names = ["notes.txt", "Script", "web.WOFF2", "font.woff"]; + for name in names { + std::fs::write(src.join(name), name).unwrap(); + } + let r = copy_into(&names.map(|n| src.join(n)), &dest); + let copied: Vec = r.copied.iter().map(|(from, _)| from.clone()).collect(); + assert_eq!(copied, [src.join("web.WOFF2"), src.join("font.woff")]); + let not_a_font = |n: &str| (src.join(n), "not a font file".to_string()); + assert_eq!(r.skipped, [not_a_font("notes.txt"), not_a_font("Script")]); + assert!(!dest.join("notes.txt").exists() && !dest.join("Script").exists()); + let _ = std::fs::remove_dir_all(&root); +} + +/// A suitcase font: a file without an extension, empty but for the fonts in its resource fork, +/// which only macOS keeps. A search finds it, and Add Fonts copies it with its fork. +#[cfg(target_os = "macos")] +#[test] +fn suitcase_fonts_are_found_and_copied_with_their_fork() { + use vectorcraft_testkit::fonts::{renamed, suitcase_fork}; + let root = folder("suitcase"); + let (src, dest) = (root.join("src"), root.join("dest")); + std::fs::create_dir_all(src.join("sub")).unwrap(); + std::fs::create_dir_all(&dest).unwrap(); + let suitcase = |path: &Path, family: &str| { + let fork = suitcase_fork(&[renamed(family)]); + std::fs::write(path, b"").unwrap(); + std::fs::write(path.join("..namedfork/rsrc"), &fork).unwrap(); + fork + }; + let found = src.join("sub/Suitme"); + let fork = suitcase(&found, "Suitme Sans 3"); + // A file without an extension that isn't a suitcase is checked and left out. + std::fs::write(src.join("README"), b"text").unwrap(); + let mut s = session_for(&root); + s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suitme Sans 3"}]})).unwrap(); + let r = finished(&mut s); + assert_eq!(r["fonts"][0]["files"], json!([found]), "{r}"); + let r = copy_into(std::slice::from_ref(&found), &dest); + assert_eq!(r.copied, [(found.clone(), dest.join("Suitme"))]); + assert!(vectorcraft_text::is_suitcase(&dest.join("Suitme"))); + assert_eq!(std::fs::read(dest.join("Suitme/..namedfork/rsrc")).unwrap(), fork); + // The same suitcase again is kept; another of that name gets a number. + assert_eq!(copy_into(std::slice::from_ref(&found), &dest).kept, [(found.clone(), dest.join("Suitme"))]); + let other = src.join("Suitme"); + suitcase(&other, "Other Sans 33"); + assert_eq!(copy_into(std::slice::from_ref(&other), &dest).copied, [(other.clone(), dest.join("Suitme 2"))]); + // A suitcase named like a font file keeps its fork too. + let named = src.join("Suitext.ttf"); + let named_fork = suitcase(&named, "Suitext Sans3"); + assert_eq!(copy_into(std::slice::from_ref(&named), &dest).copied, [(named.clone(), dest.join("Suitext.ttf"))]); + assert_eq!(std::fs::read(dest.join("Suitext.ttf/..namedfork/rsrc")).unwrap(), named_fork); + // The font scan reads the copies. + let db = vectorcraft_text::FontDb::with_font_dirs(vec![dest.clone()]); + assert_eq!((db.styles("Suitme Sans 3"), db.styles("Other Sans 33")), (vec!["Regular".to_string()], vec!["Regular".to_string()])); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs new file mode 100644 index 00000000..379daa08 --- /dev/null +++ b/crates/engine/tests/font_folder.rs @@ -0,0 +1,77 @@ +//! VectorCraft's own Fonts folder: a missing font's file, found in a folder, is copied into it +//! without replacing a file there, and type set in the font redraws in it. One test in its own +//! process, so the process-wide font database and Fonts folder start fresh, as when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::findfiles::{Rules, plain}; + +/// `text.findFontFiles {}` once the search ended (10 seconds at most). +fn finished(s: &mut Session) -> Value { + let until = Instant::now() + Duration::from_secs(10); + loop { + let r = s.execute("text.findFontFiles", &json!({})).unwrap(); + if r["state"] != "searching" || Instant::now() > until { + return r; + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { + let root = vectorcraft_testkit::temp_dir("font-folder"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Downloads/Deep")).unwrap(); + let root = plain(std::fs::canonicalize(&root).unwrap()); + let fonts = root.join("App Fonts"); + vectorcraft_text::set_app_font_dir(fonts.clone()); + assert!(vectorcraft_text::system_font_dirs().contains(&fonts)); + let file = root.join("Downloads/Deep/Findme.otf"); + std::fs::write(&file, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); + let serif = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSerif4-Regular.ttf"); + std::fs::copy(serif, root.join("other.ttf")).unwrap(); + + let mut s = Session::new(); + s.search_rules = Some(Rules { user_content: vec![root.clone()], ..Rules::default() }); + s.search_threads = Some(2); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Findme", "font": "Findme Sans 3"})).unwrap(); + let r = s.execute("text.missingFonts", &json!({})).unwrap(); + assert_eq!((r["fonts"][0]["family"].as_str(), r["fonts"][0]["status"].as_str()), (Some("Findme Sans 3"), Some("missing")), "{r}"); + s.doc_mut().unwrap().mark_saved(); + let revision = s.doc().unwrap().revision; + + s.execute("text.findFontFiles", &json!({"folder": root})).unwrap(); + let r = finished(&mut s); + assert_eq!((&r["state"], &r["fonts"][0]["files"]), (&json!("done"), &json!([file])), "{r}"); + + // A file of that name is in the Fonts folder already: it stays, and the copy takes a number. + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("Findme.otf"), b"keep").unwrap(); + let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); + assert_eq!(r["copied"], json!([{"from": file, "to": fonts.join("Findme 2.otf")}]), "{r}"); + assert!(r["faces"].as_u64().is_some_and(|n| n > 0) && r["families"].as_u64().is_some(), "{r}"); + assert_eq!(std::fs::read(fonts.join("Findme.otf")).unwrap(), b"keep"); + + // The type is set in the font now, without editing the document. + let listed = s.execute("text.fonts", &json!({})).unwrap(); + let findme = listed.as_array().unwrap().iter().find(|f| f["family"] == "Findme Sans 3").cloned().unwrap(); + assert_eq!((findme["status"].as_str(), findme["resolved"]["family"].as_str()), (Some("exact"), Some("Findme Sans 3")), "{findme}"); + let st = s.doc().unwrap(); + assert!(st.revision > revision && !st.is_dirty()); + assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + + // The same file again is kept, not copied a third time. + let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); + assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); + assert!(!fonts.join("Findme 3.otf").exists()); + // Only files the last search found are copied. + let r = s.execute("text.addFontFiles", &json!({"files": [root.join("other.ttf")]})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("the last text.findFontFiles search")), "{r:?}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f50ac1fa..c96b37e3 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -6,7 +6,9 @@ //! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files //! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of //! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents -//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions). +//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the +//! font files a folder search reads (damaged fonts, collections whose headers give any count of +//! faces). //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -1915,3 +1917,127 @@ proptest! { } } } + +// ---------- font files a folder search reads ---------- + +/// A small font file holding what a folder search reads: an outline table's tag, the `name` and +/// `OS/2` tables of the bundled Source Sans 3 renamed "Findme Sans 3", and an `fvar` table with a +/// weight axis and three named instances (named by name ids 2, 1 and 4). +fn search_font() -> Vec { + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let be32 = |at: usize| u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) as usize; + let table = |tag: &[u8; 4]| { + let n = u16::from_be_bytes([font[4], font[5]]) as usize; + let r = (0..n).map(|i| 12 + 16 * i).find(|&r| &font[r..r + 4] == tag).unwrap(); + font[be32(r + 8)..be32(r + 8) + be32(r + 12)].to_vec() + }; + // fvar 1.0: the axis array at 16, one axis of 20 bytes, three instances of 8. + let mut fvar = vec![]; + for v in [1u16, 0, 16, 2, 1, 20, 3, 8] { + fvar.extend(v.to_be_bytes()); + } + fvar.extend(b"wght"); + for v in [100i32, 400, 900] { + fvar.extend((v << 16).to_be_bytes()); + } + fvar.extend([0, 0, 1, 0]); + for (name, weight) in [(2u16, 300i32), (1, 600), (4, 900)] { + fvar.extend(name.to_be_bytes()); + fvar.extend(0u16.to_be_bytes()); + fvar.extend((weight << 16).to_be_bytes()); + } + let tables: [(&[u8; 4], Vec); 4] = [(b"OS/2", table(b"OS/2")), (b"fvar", fvar), (b"glyf", vec![0; 4]), (b"name", table(b"name"))]; + let mut out = 0x0001_0000_u32.to_be_bytes().to_vec(); + for v in [tables.len() as u16, 0, 0, 0] { + out.extend(v.to_be_bytes()); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend(*tag); + for v in [0, offset as u32, data.len() as u32] { + out.extend(v.to_be_bytes()); + } + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend(data); + out.resize(out.len().next_multiple_of(4), 0); + } + out +} + +/// `font` as a collection of `faces % 5` faces that all read it, whose header gives `faces` as the +/// count. +fn search_collection(font: &[u8], faces: u32) -> Vec { + let k = (faces % 5) as usize; + let base = 12 + 4 * k; + let mut out = b"ttcf".to_vec(); + out.extend(0x0001_0000_u32.to_be_bytes()); + out.extend(faces.to_be_bytes()); + for _ in 0..k { + out.extend((base as u32).to_be_bytes()); + } + // The tables' offsets count from the start of the collection. + let mut f = font.to_vec(); + let n = u16::from_be_bytes([f[4], f[5]]) as usize; + for i in 0..n { + let at = 12 + 16 * i + 8; + let offset = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32; + f[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + } + out.extend(f); + out +} + +/// The fonts a search for "Findme Sans 3" looks for: the face, and a style of the family as if the +/// family were installed. +fn findme_wanted() -> vectorcraft_text::WantedFonts { + use vectorcraft_text::WantedFont; + vectorcraft_text::WantedFonts::new(&[ + WantedFont { family: "Findme Sans 3".into(), style: "Regular".into(), installed: None }, + WantedFont { family: "FINDME SANS 3".into(), style: "Black".into(), installed: Some("Findme Sans 3".into()) }, + WantedFont { family: "SourceSans3-Regular".into(), style: "Bold".into(), installed: None }, + ]) +} + +/// Whether reading `bytes` as a font file a search reads panics. +fn search_reads(what: &str, bytes: &[u8]) -> Result, TestCaseError> { + let path = vectorcraft_testkit::temp_dir("font-search-fuzz").join(format!("{:?}.ttc", std::thread::current().id())); + std::fs::write(&path, bytes).unwrap(); + catch_quiet(|| findme_wanted().provided_by(&path)).map_err(|e| TestCaseError::fail(format!("{what}: a search reading the file panicked: {e}"))) +} + +/// The samples the fuzz properties mutate reach the matching: each provides the face it names. +#[test] +fn the_search_font_samples_are_read_whole() { + let font = search_font(); + assert_eq!(search_reads("font", &font).unwrap(), [0, 2]); + assert_eq!(search_reads("collection", &search_collection(&font, 3)).unwrap(), [0, 2]); + // Two faces, and a header that gives seven. + assert_eq!(search_reads("collection claiming more faces", &search_collection(&font, 7)).unwrap(), [0, 2]); +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn font_files_a_search_reads_never_panic( + faces in prop::option::of(any::()), + cut in prop::option::of(0usize..12_000), + edits in prop::collection::vec((0usize..12_000, any::()), 0..12), + ) { + let font = search_font(); + let mut bytes = match faces { + Some(n) => search_collection(&font, n), + None => font, + }; + for (at, b) in edits { + let n = bytes.len(); + bytes[at % n] = b; + } + if let Some(cut) = cut { + bytes.truncate(cut); + } + search_reads("mutated font file", &bytes)?; + } +} diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index db1d53d2..c2948a94 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -1,5 +1,6 @@ //! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed //! or interrupted write never leaves a damaged file behind (the old one stays as it was). +//! [`write_new_with`] creates a file the same way without ever replacing one. use std::fs::{File, OpenOptions}; use std::io::{self, Write as _}; @@ -26,6 +27,48 @@ pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result }) } +/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The +/// content goes to a temporary file beside it, which is then linked in under `path` +/// (`std::fs::hard_link`, which fails with `AlreadyExists` when the name is taken, also when it was +/// taken meanwhile). The file appears whole or not at all. Where hard links fail for another +/// reason, `path` is created with `create_new` and the content copied in; a failed copy removes +/// it. The temporary file is always removed. +pub fn write_new_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + write_new_linking(path, fill, |tmp, path| std::fs::hard_link(tmp, path)) +} + +/// [`write_new_with`], with `link` linking the temporary file in under `path`. +fn write_new_linking( + path: &Path, + fill: impl FnOnce(&mut File) -> io::Result<()>, + link: impl FnOnce(&Path, &Path) -> io::Result<()>, +) -> io::Result<()> { + let tmp = temp_path(path)?; + let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?; + let filled = fill(&mut f).and_then(|()| f.sync_all()); + drop(f); + let placed = filled.and_then(|()| match link(&tmp, path) { + Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path), + linked => linked, + }); + // Best effort: the temporary file is all there is to clean up. + let _ = std::fs::remove_file(&tmp); + placed +} + +/// Create `path` (never replacing anything) with the content of the file `from`; a failed copy +/// removes it again. +fn copy_new(from: &Path, path: &Path) -> io::Result<()> { + let mut to = OpenOptions::new().write(true).create_new(true).open(path)?; + let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all()); + drop(to); + if copied.is_err() { + // Best effort: the part copied is all there is to clean up. + let _ = std::fs::remove_file(path); + } + copied +} + /// A symbolic link keeps pointing at its file: write the file it names. fn resolve_link(path: &Path) -> io::Result { match std::fs::symlink_metadata(path) { @@ -93,4 +136,64 @@ mod tests { assert!(write_atomic(&d.join("missing").join("x.vectorcraft"), b"x").is_err()); let _ = std::fs::remove_dir_all(&d); } + + #[test] + fn write_new_never_replaces_what_is_there() { + let d = dir("new"); + let path = d.join("a.otf"); + write_new_with(&path, |f| f.write_all(b"first")).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + assert!(leftovers(&d, "a.otf").is_empty(), "the temporary file is removed"); + let r = write_new_with(&path, |f| f.write_all(b"second")); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists)); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + assert!(leftovers(&d, "a.otf").is_empty()); + // A failed write leaves nothing. + let failed = d.join("b.otf"); + assert!(write_new_with(&failed, |f| f.write_all(b"half").and(Err(io::Error::other("disk full")))).is_err()); + assert!(!failed.exists() && leftovers(&d, "a.otf").is_empty()); + let _ = std::fs::remove_dir_all(&d); + } + + /// Where hard links fail (a file system without them), the content is copied in, and a taken + /// name stays as it was. A link that fails because the name is taken copies nothing. + #[test] + fn write_new_copies_where_hard_links_fail() { + let d = dir("new-copy"); + let unsupported = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::Unsupported)); + let path = d.join("c.otf"); + write_new_linking(&path, |f| f.write_all(b"copied"), unsupported).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"copied"); + assert!(leftovers(&d, "c.otf").is_empty(), "the temporary file is removed"); + let r = write_new_linking(&path, |f| f.write_all(b"second"), unsupported); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists)); + assert_eq!(std::fs::read(&path).unwrap(), b"copied"); + let taken = |_: &Path, _: &Path| Err(io::Error::from(io::ErrorKind::AlreadyExists)); + let other = d.join("d.otf"); + let r = write_new_linking(&other, |f| f.write_all(b"other"), taken); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists)); + assert!(!other.exists() && leftovers(&d, "c.otf").is_empty()); + let _ = std::fs::remove_dir_all(&d); + } + + /// A link of that name, even one to nothing, is never replaced or written through. + #[cfg(unix)] + #[test] + fn write_new_leaves_links_alone() { + let d = dir("new-links"); + let target = d.join("target.otf"); + std::fs::write(&target, b"target").unwrap(); + std::os::unix::fs::symlink(&target, d.join("link.otf")).unwrap(); + std::os::unix::fs::symlink(d.join("nothing.otf"), d.join("dangling.otf")).unwrap(); + for name in ["link.otf", "dangling.otf"] { + let r = write_new_with(&d.join(name), |f| f.write_all(b"new")); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists), "{name}"); + } + assert_eq!(std::fs::read(&target).unwrap(), b"target"); + assert!(!d.join("nothing.otf").exists()); + let mut names: Vec = std::fs::read_dir(&d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + names.sort(); + assert_eq!(names, ["dangling.otf", "link.otf", "target.otf"], "no temporary file is left"); + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index e49c7e0f..42ea6f3d 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -43,7 +43,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] -pub use atomic::{write_atomic, write_atomic_with}; +pub use atomic::{write_atomic, write_atomic_with, write_new_with}; /// v4: large data after the JSON (v1 to v3 files still load). pub const VERSION: u32 = 4; diff --git a/crates/testkit/src/fonts.rs b/crates/testkit/src/fonts.rs new file mode 100644 index 00000000..c61c3a64 --- /dev/null +++ b/crates/testkit/src/fonts.rs @@ -0,0 +1,69 @@ +//! Font files for tests, made from the bundled fonts when a test runs: no font file is committed. + +/// The bundled Source Sans 3 Regular with its family renamed `family` in every record of its name +/// table, UTF-16 and ASCII alike. `family` must be as long as "Source Sans 3" (13 bytes). The +/// PostScript name stays `SourceSans3-Regular`. +pub fn renamed(family: &str) -> Vec { + const ORIGINAL: &str = "Source Sans 3"; + assert_eq!(family.len(), ORIGINAL.len(), "`{family}` must be 13 bytes long"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf"); + let mut data = std::fs::read(path).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(ORIGINAL), utf16(family)), (ORIGINAL.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// A resource fork holding `fonts` as `sfnt` resources and one other resource (a `vers`), as a font +/// utility writes a suitcase font's fork, and as vectorcraft-text's suitcase tests build one. macOS +/// keeps a file's resource fork at `/..namedfork/rsrc`. +pub fn suitcase_fork(fonts: &[Vec]) -> Vec { + let mut data = Vec::new(); + let mut offsets = Vec::new(); + for body in fonts.iter().map(Vec::as_slice).chain([&b"1.0"[..]]) { + offsets.push(data.len() as u32); + data.extend_from_slice(&(body.len() as u32).to_be_bytes()); + data.extend_from_slice(body); + } + let n = fonts.len(); + // The type list: `vers` (1 resource) then `sfnt`, each entry pointing at its references. + let list = 2 + 2 * 8; + let mut types = Vec::new(); + types.extend_from_slice(&1u16.to_be_bytes()); + types.extend_from_slice(b"vers"); + types.extend_from_slice(&0u16.to_be_bytes()); + types.extend_from_slice(&(list as u16).to_be_bytes()); + types.extend_from_slice(b"sfnt"); + types.extend_from_slice(&((n - 1) as u16).to_be_bytes()); + types.extend_from_slice(&((list + 12) as u16).to_be_bytes()); + let reference = |id: i16, offset: u32| { + let mut r = Vec::new(); + r.extend_from_slice(&id.to_be_bytes()); + r.extend_from_slice(&0xFFFFu16.to_be_bytes()); + r.extend_from_slice(&offset.to_be_bytes()); + r.extend_from_slice(&[0; 4]); + r + }; + types.extend(reference(1, offsets[n])); + for (i, o) in offsets[..n].iter().enumerate() { + types.extend(reference(5000 + i as i16, *o)); + } + // The map: a copy of the header, a handle, attributes, then the offsets of the lists. + let mut map = vec![0; 24]; + map.extend_from_slice(&28u16.to_be_bytes()); + map.extend_from_slice(&((28 + types.len()) as u16).to_be_bytes()); + map.extend(types); + let mut fork = Vec::new(); + for v in [256u32, 256 + data.len() as u32, data.len() as u32, map.len() as u32] { + fork.extend_from_slice(&v.to_be_bytes()); + } + fork.resize(256, 0); + fork.extend(data); + fork.extend(map); + fork +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 834e3a1a..af8b7d47 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -12,6 +12,7 @@ //! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests. //! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it. //! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests. +//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests. //! //! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`). // Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion @@ -23,6 +24,7 @@ pub mod ai; pub mod ase; pub mod fixtures; +pub mod fonts; pub mod geom; pub mod invariants; pub mod pdf; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index edd3bcdd..17fc5d58 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -801,28 +801,41 @@ fn font_files(path: &Path) -> Vec> { const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; /// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table -/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them -/// megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table) are -/// left out. +/// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of +/// them megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table) +/// are left out. #[cfg(not(target_arch = "wasm32"))] fn file_face_names(path: &Path) -> Vec { + /// The most faces read from a (possibly damaged) collection. + const MAX_FACES: u32 = 256; + file_face_names_within(path, MAX_FACES, u64::MAX) +} + +/// [`file_face_names`], reading at most `max_faces` faces and `max_read` bytes of the file in all +/// (of each font in all, for a suitcase font). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn file_face_names_within(path: &Path, max_faces: u32, max_read: u64) -> Vec { if crate::suitcase::is_suitcase(path) { - return crate::suitcase::read_fonts(path).into_iter().flat_map(|font| reader_face_names(std::io::Cursor::new(font))).collect(); + return crate::suitcase::read_fonts(path) + .into_iter() + .flat_map(|font| reader_face_names_within(std::io::Cursor::new(font), max_faces, max_read)) + .collect(); } match std::fs::File::open(path) { - Ok(file) => reader_face_names(file), + Ok(file) => reader_face_names_within(file, max_faces, max_read), Err(_) => vec![], } } -/// [`file_face_names`] for the font file `file` reads. +/// [`file_face_names_within`] for the font file `file` reads. #[cfg(not(target_arch = "wasm32"))] -fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec { +fn reader_face_names_within(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64) -> Vec { use std::io::SeekFrom; - /// Caps on what a (possibly damaged) file can make the scan read. - const MAX_FACES: u32 = 256; + /// The largest table read from a (possibly damaged) file. const MAX_NAME_TABLE: u32 = 1 << 20; + let mut read = 0u64; let mut read_at = |offset: u64, len: usize| -> Option> { + read = read.checked_add(u64::try_from(len).ok()?).filter(|r| *r <= max_read)?; let mut buf = vec![0; len]; file.seek(SeekFrom::Start(offset)).ok()?; file.read_exact(&mut buf).ok()?; @@ -832,7 +845,7 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec = if head.starts_with(b"ttcf") { - let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + let n = be32(&head, 8).unwrap_or(0).min(max_faces) as usize; read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() } else { vec![0] @@ -874,6 +887,157 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["ttf", "otf", "ttc", "otc"].iter().any(|x| e.eq_ignore_ascii_case(x))) +} + +/// Whether the file at `path` is a suitcase font ([`crate::suitcase`]): on macOS, a file with +/// nothing in its data and its fonts in its resource fork. Never on the web. +pub fn is_suitcase(path: &Path) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + crate::suitcase::is_suitcase(path) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + false + } +} + +/// A font a document names that isn't available, to look for in font files ([`WantedFonts`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WantedFont { + pub family: String, + pub style: String, + /// When the family is installed but not the style ([`FontMatch::Style`]): the family of the + /// face [`FontDb::resolve`] gives instead. `None` when the family is missing. + pub installed: Option, +} + +/// Fonts to look for in font files: [`WantedFonts::provided_by`] tells which of them a file +/// provides. +#[derive(Debug, Default)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct WantedFonts { + fonts: Vec, + /// The indexes of the fonts by every name a face could answer to them by: the family as given + /// and the installed family (ASCII-lowercased), and the family normalized ([`norm`]). + by_name: HashMap>, +} + +impl WantedFonts { + /// The most faces and bytes of one file a search reads. + #[cfg(not(target_arch = "wasm32"))] + const SEARCH_FACES: u32 = 64; + #[cfg(not(target_arch = "wasm32"))] + const SEARCH_READ: u64 = 8 << 20; + + pub fn new(fonts: &[WantedFont]) -> Self { + let mut by_name: HashMap> = HashMap::new(); + for (i, w) in fonts.iter().enumerate() { + let mut names = vec![w.family.to_ascii_lowercase(), norm(&w.family)]; + names.extend(w.installed.as_ref().map(|c| c.to_ascii_lowercase())); + names.sort(); + names.dedup(); + for n in names.into_iter().filter(|n| !n.is_empty()) { + by_name.entry(n).or_default().push(i); + } + } + Self { fonts: fonts.to_vec(), by_name } + } + + pub fn len(&self) -> usize { + self.fonts.len() + } + + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + /// The indexes of the fonts that the font file at `path` provides: copied into a folder the + /// font scan reads, the file makes [`FontDb::resolve`] find each of them exactly, in one of its + /// faces or a variable font's named instances. The file is read as the scan reads it (its + /// table directories and its `name`, `fvar` and `OS/2` tables), at most 64 faces and 8 MB of + /// it, when it is a font file by its extension ([`is_font_file`]) or a suitcase font. Sorted; + /// none on the web. + pub fn provided_by(&self, path: &Path) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + { + if !is_font_file(path) && !crate::suitcase::is_suitcase(path) { + return Vec::new(); + } + let faces = file_face_names_within(path, Self::SEARCH_FACES, Self::SEARCH_READ); + self.provided_by_faces(&faces) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + Vec::new() + } + } + + /// [`Self::provided_by`] for the faces of a file. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn provided_by_faces(&self, faces: &[FaceStyle]) -> Vec { + let mut candidates: Vec = Vec::new(); + for f in faces { + let names = std::iter::once(f.family.to_ascii_lowercase()) + .chain(f.keys.families.iter().cloned()) + .chain(f.keys.legacy.iter().map(|(family, _)| family.clone())) + .chain(f.keys.postscript.iter().cloned()); + for n in names { + candidates.extend(self.by_name.get(&n).into_iter().flatten()); + } + } + candidates.sort_unstable(); + candidates.dedup(); + candidates.retain(|i| self.fonts.get(*i).is_some_and(|w| provides(w, faces))); + candidates + } +} + +/// Whether the faces of a font file, cataloged by a rescan, make [`FontDb::resolve`] find `w` +/// exactly in one of them: [`FontDb::canonical`] and [`FontDb::find`] over these faces, as they +/// would run once the file is cataloged. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn provides(w: &WantedFont, faces: &[FaceStyle]) -> bool { + let sk = norm(&w.style); + // A face of `family` with the style among its style names, as `find` looks for it once the + // family's files are loaded. + let styled = |family: &str| faces.iter().any(|f| f.family.eq_ignore_ascii_case(family) && f.keys.styles.contains(&sk)); + let named_as_given = faces.iter().any(|f| f.family.eq_ignore_ascii_case(&w.family)); + if let Some(installed) = &w.installed { + // A family cataloged by the name as given is found by it. + if named_as_given && !w.family.eq_ignore_ascii_case(installed) { + return styled(&w.family); + } + // The installed family is loaded: a face of it is loaded from the file only when its own + // style is the one asked for (`is_cataloged`). + return faces.iter().any(|f| f.family.eq_ignore_ascii_case(installed) && norm(&f.style) == sk); + } + if named_as_given { + return styled(&w.family); + } + let key = norm(&w.family); + if key.is_empty() { + return false; + } + // Another of a family's names keeps the style asked for. + if let Some(f) = faces.iter().find(|f| f.keys.families.contains(&key)) { + return styled(&f.family); + } + // A legacy family with its legacy style, or a PostScript name, names its face whatever style + // is asked for. + if faces.iter().any(|f| f.keys.legacy.iter().any(|(family, style)| *family == key && *style == sk) || f.keys.postscript.contains(&key)) { + return true; + } + // A legacy family with another style: that style of the family. + faces.iter().find(|f| f.keys.legacy.iter().any(|(family, _)| *family == key)).is_some_and(|f| styled(&f.family)) +} + /// A font file holding `tables` (tag, data), sorted by tag as table directories are. #[cfg(any(test, not(target_arch = "wasm32")))] pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option> { @@ -947,9 +1111,34 @@ pub fn user_font_dirs() -> Vec { Vec::new() } -/// The platform's font folders (the system's and the user's) and the font files known to it -/// outside them (on Windows those registered with it, and those [`set_platform_font_files`] -/// lists), scanned by [`FontDb::global`]. +/// VectorCraft's own Fonts folder, set by the app ([`set_app_font_dir`]). +#[cfg(not(target_arch = "wasm32"))] +static APP_FONT_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Have [`system_font_dirs`] read `dir` with its subfolders: VectorCraft's own Fonts folder, which +/// `text.addFontFiles` copies font files into. The desktop app and `vectorcraft-cli` set it before +/// the first scan, as they install [`set_platform_font_files`]. A folder that doesn't exist yet is +/// read once it does. Only the first call counts. Ignored on wasm. +pub fn set_app_font_dir(dir: PathBuf) { + #[cfg(not(target_arch = "wasm32"))] + // A second call keeps the first folder, as documented. + let _ = APP_FONT_DIR.set(dir); + #[cfg(target_arch = "wasm32")] + let _ = dir; +} + +/// The folder [`set_app_font_dir`] set: none before that, and none on wasm. +pub fn app_font_dir() -> Option<&'static Path> { + #[cfg(not(target_arch = "wasm32"))] + return APP_FONT_DIR.get().map(PathBuf::as_path); + #[cfg(target_arch = "wasm32")] + None +} + +/// The platform's font folders (the system's and the user's), VectorCraft's own Fonts folder +/// ([`set_app_font_dir`]), the font files known to the platform outside them (on Windows those +/// registered with it, and those [`set_platform_font_files`] lists) and the folders the user added +/// ([`set_user_font_dirs`]), scanned by [`FontDb::global`]. pub fn system_font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); if cfg!(target_arch = "wasm32") { @@ -990,6 +1179,7 @@ pub fn system_font_dirs() -> Vec { // In a Flatpak sandbox, the host's fonts (system, local and the user's). dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into)); } + dirs.extend(app_font_dir().map(Path::to_path_buf)); #[cfg(not(target_arch = "wasm32"))] if let Some(list) = PLATFORM_FONT_FILES.get() { let files = fonts_outside(list(), &dirs); @@ -1402,8 +1592,7 @@ impl FontDb { // A file named by itself is a font whatever its name (font services keep fonts in // files without an extension); one of a folder's only with a font's extension, or // a suitcase font (whose fonts are in its resource fork, whatever its name). - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && !crate::suitcase::is_suitcase(&p) { + if !named && !is_font_file(&p) && !crate::suitcase::is_suitcase(&p) { continue; } for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1115df74..5e87f9ec 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -27,8 +27,8 @@ pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs, - style_weight, system_font_dirs, user_font_dirs, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir, + is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index a5f2e62a..700cc826 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -318,3 +318,207 @@ fn the_scan_reads_suitcase_fonts() { assert_eq!(bold.path(), Some(suitcase.as_path())); assert_eq!(db.find_family("SysfontSans3").as_deref(), Some(FAMILY), "by a PostScript-style name, as an EPS names it"); } + +/// The bundled Source Sans 3 Regular with a `name` table of `records` (platform, language, name id, +/// text): Windows Unicode (UTF-16) or Mac Roman (ASCII here). +fn named(records: &[(u16, u16, u16, &str)]) -> Vec { + let mut records = records.to_vec(); + records.sort_by_key(|r| (r.0, r.1, r.2)); + let (mut name, mut strings) = (vec![], vec![]); + let n = records.len() as u16; + for v in [0, n, 6 + 12 * n] { + name.extend_from_slice(&v.to_be_bytes()); + } + for &(platform, lang, id, s) in &records { + let bytes: Vec = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() }; + let encoding = if platform == 3 { 1 } else { 0 }; + for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] { + name.extend_from_slice(&v.to_be_bytes()); + } + strings.extend(bytes); + } + name.extend(strings); + let base = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + let font = skrifa::FontRef::new(&base).unwrap(); + let mut builder = write_fonts::FontBuilder::new(); + builder.add_raw(write_fonts::types::Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + builder.build() +} + +const EN: u16 = 0x0409; +const JA: u16 = 0x0411; + +/// Copying a file a search offers into a folder the scan reads makes the font resolve exactly from +/// that file, and a file it doesn't offer doesn't: the search matches fonts as `resolve` finds +/// them. Each case resolves the font, copies the file into a scanned folder, rescans and resolves +/// again, as the app does. +#[test] +fn wanted_fonts_match_as_resolve_finds_them() { + let one = named(&[(3, EN, 1, "Parity One"), (3, EN, 2, "Regular"), (3, EN, 6, "ParityOne-Regular")]); + let two = named(&[ + (3, EN, 1, "Parity Two"), + (3, EN, 2, "Regular"), + (3, EN, 6, "ParityTwo-Regular"), + (3, EN, 16, "Parity Two"), + (3, JA, 16, "パリティ二"), + (3, EN, 17, "Regular"), + ]); + // Laid out like Hiragino Sans W3: a legacy family per weight, paired with its legacy style, and + // a PostScript name that reads as the legacy family. + let three = |postscript: &str| { + named(&[ + (1, 0, 1, "Parity Three"), + (1, 0, 2, "W3"), + (3, EN, 1, "Parity Three W3"), + (3, EN, 2, "Regular"), + (3, EN, 6, postscript), + (3, EN, 16, "Parity Three"), + (3, EN, 17, "W3"), + (3, JA, 1, "パリティ三 W3"), + (3, JA, 2, "Regular"), + (3, JA, 16, "パリティ三"), + (3, JA, 17, "W3"), + ]) + }; + let (three, three_ps) = (three("ParityThree-W3"), three("PThree-W3")); + // A PostScript name that is the family's name. + let findme = named(&[(3, EN, 1, "Findme"), (3, EN, 2, "Regular"), (3, EN, 6, "Findme")]); + let psdiff = named(&[(3, EN, 1, "Psdiff Sans"), (3, EN, 2, "Regular"), (3, EN, 6, "PsdiffSans-Regular")]); + let variable = crate::test_fonts::variable_font().unwrap(); + let four = |style: &str, ja_style: &str| { + named(&[ + (3, EN, 1, "Parity Four"), + (3, EN, 2, style), + (3, EN, 6, &format!("ParityFour-{style}")), + (3, EN, 16, "Parity Four"), + (3, EN, 17, style), + (3, JA, 16, "パリティ四"), + (3, JA, 17, ja_style), + ]) + }; + let (four_regular, four_bold) = (four("Regular", "標準"), four("Bold", "太字")); + let five = named(&[(3, EN, 1, "Parity Five"), (3, EN, 2, "Bold"), (3, EN, 6, "ParityFive-Bold"), (3, EN, 17, "Bold"), (3, JA, 17, "太字")]); + // (family, style, Parity Four Regular installed, candidate file, provides it) + let cases: Vec<(&str, &str, bool, &[u8], bool)> = vec![ + ("Parity One", "Regular", false, &one, true), + ("parity one", "REGULAR", false, &one, true), + ("Parity One", "Bold", false, &one, false), + ("Parity One", "Regular", false, &four_bold, false), + ("パリティ二", "Regular", false, &two, true), + ("パリティ二", "Bold", false, &two, false), + ("Parity Three W3", "Regular", false, &three_ps, true), + ("Parity Three W3", "Bold", false, &three_ps, false), + ("PThree-W3", "Bold", false, &three_ps, true), + // The legacy family reads as the PostScript name, which names the W3 face. + ("Parity Three W3", "Bold", false, &three, true), + ("パリティ三", "W3", false, &three, true), + ("パリティ三", "Bold", false, &three, false), + ("Findme", "Bold", false, &findme, false), + ("Find-me", "Regular", false, &findme, true), + // A family's name goes before a PostScript name that reads the same. + ("Find-me", "Bold", false, &findme, false), + ("PsdiffSans-Regular", "Bold", false, &psdiff, true), + ("Varitest Sans", "SemiBold", false, &variable, true), + ("Varitest Sans", "Normal", false, &variable, true), + ("VaritestSans-Bold", "Regular", false, &variable, true), + ("Varitest Sans", "Black", false, &variable, false), + ("Parity Five", "太字", false, &five, true), + ("Parity Four", "Bold", true, &four_bold, true), + ("パリティ四", "Bold", true, &four_bold, true), + ("Parity Four", "太字", true, &four_bold, false), + ("Parity Four", "Bold", true, &one, false), + ]; + for (i, (family, style, installed, candidate, provides)) in cases.into_iter().enumerate() { + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-wanted-{i}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (base, new) = (dir.join("base"), dir.join("new")); + std::fs::create_dir_all(&base).unwrap(); + std::fs::create_dir_all(&new).unwrap(); + if installed { + std::fs::write(base.join("ParityFour-Regular.ttf"), &four_regular).unwrap(); + } + let db = FontDb::with_font_dirs(vec![base, new.clone()]); + let (face, m) = db.resolve(family, style).unwrap(); + assert_eq!(m, if installed { FontMatch::Style } else { FontMatch::Missing }, "case {i}: {family} {style}"); + let wanted = WantedFont { family: family.into(), style: style.into(), installed: (m == FontMatch::Style).then(|| face.family.clone()) }; + let file = new.join("Candidate.ttf"); + std::fs::write(&file, candidate).unwrap(); + db.load_system_fonts(); + let (face, m) = db.resolve(family, style).unwrap(); + let exact = m == FontMatch::Exact && face.path().map(|p| std::fs::canonicalize(p).unwrap()) == Some(std::fs::canonicalize(&file).unwrap()); + assert_eq!(exact, provides, "case {i}: resolve gave {face:?} {m:?} for {family} {style}"); + assert_eq!(WantedFonts::new(&[wanted]).provided_by(&file) == [0], provides, "case {i}: the search, for {family} {style}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// A search reads only so much of a file: of a collection whose header gives 300 faces it reads +/// the first 64, and a cap on the bytes read ends it early. +#[test] +fn a_search_reads_at_most_its_share_of_a_font_file() { + use crate::fontdb::file_face_names_within; + let face = renamed("SourceSans3-Regular.ttf"); + let tables = u16::from_be_bytes([face[4], face[5]]) as usize; + let len = |tag: &[u8]| { + let r = (0..tables).map(|i| 12 + 16 * i).find(|&r| &face[r..r + 4] == tag).unwrap(); + u64::from(u32::from_be_bytes(face[r + 12..r + 16].try_into().unwrap())) + }; + // What one face costs: its table directory and its name and OS/2 tables. + let per_face = 12 + 16 * tables as u64 + len(b"name") + len(b"OS/2"); + let n = 300; + let base = 12 + 4 * n; + let mut ttc = b"ttcf".to_vec(); + ttc.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + ttc.extend_from_slice(&(n as u32).to_be_bytes()); + for _ in 0..n { + ttc.extend_from_slice(&(base as u32).to_be_bytes()); + } + // Every face is the one font, whose table offsets count from the start of the collection. + let mut f = face.clone(); + for r in 0..tables { + let at = 12 + r * 16 + 8; + let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + base as u32; + f[at..at + 4].copy_from_slice(&off.to_be_bytes()); + } + ttc.extend(f); + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-many", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Many.ttc"); + std::fs::write(&file, ttc).unwrap(); + assert_eq!(file_face_names_within(&file, 256, u64::MAX).len(), 256); + assert_eq!(file_face_names_within(&file, 64, u64::MAX).len(), 64); + // The header, 64 offsets and three faces. + assert_eq!(file_face_names_within(&file, 64, 12 + 4 * 64 + 3 * per_face).len(), 3); + let wanted = WantedFonts::new(&[WantedFont { family: FAMILY.into(), style: "Regular".into(), installed: None }]); + assert_eq!(wanted.provided_by(&file), [0]); + assert!(wanted.provided_by(&dir.join("No Such.ttf")).is_empty()); +} + +#[test] +fn font_files_are_told_by_their_extension() { + for name in ["a/B.TTF", "c.otf", "d.Ttc", "e.otc"] { + assert!(is_font_file(Path::new(name)), "{name}"); + } + for name in ["f.ttf.txt", "ttf", ".29457", "g.woff2", "h.fon"] { + assert!(!is_font_file(Path::new(name)), "{name}"); + } +} + +/// VectorCraft's own Fonts folder joins the scan once set, even before it exists; only the first +/// folder set counts. +#[test] +fn the_app_font_folder_is_read_by_the_scan() { + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-app-fonts", std::process::id())); + set_app_font_dir(dir.clone()); + set_app_font_dir(dir.join("Other")); + assert_eq!(app_font_dir(), Some(dir.as_path())); + let dirs = system_font_dirs(); + assert_eq!(dirs.iter().filter(|d| **d == dir).count(), 1, "{dirs:?}"); + assert!(!dirs.contains(&dir.join("Other"))); +} diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs new file mode 100644 index 00000000..5e329f9d --- /dev/null +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -0,0 +1,763 @@ +//! The fonts a document opened without (`text.missingFonts`), listed once the missing linked file +//! questions (dialog `missingLinks`) are answered, one document at a time ([`super::settle`]), with +//! Find Fonts (Type › Find Font on the first of them, to replace it) and Close. Neither brings the +//! dialog back for that open. Search Fonts Folder (the `Fonts` folder next to the document, as +//! File › Package writes it) and Find in Folder… look for their files (`text.findFontFiles`, on +//! separate threads), and Add Fonts copies the files the user chooses into VectorCraft's own Fonts +//! folder (`text.addFontFiles`). Type › Find Font's Find in Folder… opens it too +//! (`ui.findFontsInFolder`), and so does `ui.missingFontsDialog`. +//! +//! Fields: `document` (the document's uid), `fonts` (`text.missingFonts`'s rows), +//! `fontsNextToDocument` (or empty), `state` (empty, `searching`, `done`, `stopped` or `failed`), +//! `search` (the search's `id`), `folder`, `found` (`text.findFontFiles`'s `fonts`, with the files +//! found), `searched`, `skipped`, `unreadable`, `stopped`, `error`, `chosen` (the files to add; a +//! font's first file is chosen when it is found), `searchFolder` (set it and confirm to search that +//! folder) and `discard` (set it and confirm for Find Fonts; confirming without either adds the +//! chosen files). + +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::picks::PickRequest; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the fonts a document opened without. +pub const KIND: &str = "missingFonts"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Missing Fonts").into(), + body, + confirm, + // Close, with Find Fonts beside it; Add Fonts is a button of the body. + ok: None, + discard: Some("Find Fonts"), + min_width: 440.0, + max_width: Some(540.0), + ..DialogSpec::FORM +}; + +/// After a document opened (`document.open`'s result `r`): its Missing Fonts dialog waits for the +/// dialog slot. On the web the status line shows how many fonts are missing instead, unless it +/// shows the open's notes. +pub fn after_open(app: &mut VectorcraftApp, r: &Value) { + after_open_for(app, r, cfg!(target_arch = "wasm32")); +} + +fn after_open_for(app: &mut VectorcraftApp, r: &Value, web: bool) { + let Some(st) = app.session.active() else { return }; + let uid = st.uid; + if web { + let n = vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).len(); + if n > 0 && r["warnings"].as_array().is_none_or(Vec::is_empty) { + app.status(format!("{n} font(s) aren't available here: their text shows in another font")); + } + return; + } + if !app.ui.pending_fonts.contains(&uid) { + app.ui.pending_fonts.push(uid); + } +} + +/// The Missing Fonts dialog of the open document `uid`, when fonts it uses aren't available. +fn dialog_for(app: &VectorcraftApp, uid: u64) -> Option { + let st = app.session.documents().iter().find(|d| d.uid == uid)?; + let r = vectorcraft_engine::cmd::fontfiles::missing_fonts_of(st, app.session.search_rules.as_ref()); + let fonts = r["fonts"].as_array().filter(|f| !f.is_empty())?; + let next = r["fontsNextToDocument"].as_str().unwrap_or_default(); + Some(Dialog::new(KIND, json!({ "document": uid, "fonts": fonts, "fontsNextToDocument": next, "state": "", "chosen": [] }))) +} + +/// With no dialog open: the dialog of the first queued document that is still open and still +/// misses fonts. +pub(super) fn open_next(app: &mut VectorcraftApp) { + while app.ui.dialog.is_none() && !app.ui.pending_fonts.is_empty() { + let uid = app.ui.pending_fonts.remove(0); + app.ui.dialog = dialog_for(app, uid); + if app.ui.dialog.is_some() { + app.ui.fonts_dialog = Some(uid); + } + } +} + +/// Put the document `uid` at the front of the queue. +fn queue_first(app: &mut VectorcraftApp, uid: u64) { + app.ui.pending_fonts.retain(|u| *u != uid); + app.ui.pending_fonts.insert(0, uid); +} + +/// Once the Missing Fonts dialog on show is gone: when another dialog took its place (Import PDF +/// for a file opened after the document, for example), its document goes back to the front of the +/// queue, and the dialog opens again once no dialog is open. A dialog closed by Cancel, Add Fonts +/// or Escape stays closed. +pub(super) fn requeue_replaced(app: &mut VectorcraftApp) { + let Some(uid) = app.ui.fonts_dialog else { return }; + let Some(d) = &app.ui.dialog else { + app.ui.fonts_dialog = None; + return; + }; + if d.kind != KIND || d.fields.get("document").and_then(Value::as_u64) != Some(uid) { + app.ui.fonts_dialog = None; + queue_first(app, uid); + } +} + +/// Stop the search the dialog started once the dialog is gone (closed, or another in its place). +pub(super) fn stop_when_closed(app: &mut VectorcraftApp) { + let Some(id) = app.ui.dialog_search else { return }; + let open = app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND && d.fields.get("search").and_then(Value::as_u64) == Some(id)); + if !open { + app.ui.dialog_search = None; + vectorcraft_engine::cmd::fontfiles::stop(&mut app.session, id); + } +} + +/// `ui.missingFontsDialog`: the dialog for the active document; with `folder`, searching it. +pub(crate) fn open_command(app: &mut VectorcraftApp, folder: Option) -> Result { + let uid = app.session.active().map(|d| d.uid).ok_or("no document open")?; + let mut d = dialog_for(app, uid).ok_or("No fonts are missing in this document")?; + if let Some(folder) = folder { + start_search(app, &mut d, &folder)?; + } + show_for(app, uid, d); + Ok(Value::Null) +} + +/// Show `d`, the dialog of the document `uid`, in place of the open dialog (another document's +/// Missing Fonts dialog asks again later). +fn show_for(app: &mut VectorcraftApp, uid: u64, d: Dialog) { + requeue_replaced(app); + if let Some(shown) = app.ui.fonts_dialog.filter(|shown| *shown != uid) { + queue_first(app, shown); + } + app.ui.pending_fonts.retain(|u| *u != uid); + app.ui.fonts_dialog = Some(uid); + app.ui.dialog = Some(d); +} + +/// `ui.findFontsInFolder` (Type › Find Font's Find in Folder…): ask for a folder (or take +/// `folder`), then open the dialog for the active document, searching it. +pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option) -> Result { + let uid = app.session.active().map(|d| d.uid).ok_or("no document open")?; + // Nothing to look for: no folder is asked for. + let mut d = dialog_for(app, uid).ok_or("No fonts are missing in this document")?; + let folder = match folder { + Some(f) => f, + None => crate::picks::folder(app).ok_or("cancelled")?, + }; + start_search(app, &mut d, &folder)?; + show_for(app, uid, d); + Ok(Value::Null) +} + +/// Start looking in `folder` for the files of the dialog's fonts; what an earlier search found is +/// dropped. +fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Result<(), String> { + let fonts: Vec = d + .fields + .get("fonts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|f| json!({ "family": f["family"], "style": f["style"] })) + .collect(); + let r = app.run("text.findFontFiles", json!({ "folder": folder, "fonts": fonts }))?; + for k in ["found", "searched", "skipped", "unreadable", "stopped", "error"] { + d.fields.remove(k); + } + d.fields.insert("chosen".into(), json!([])); + d.fields.insert("search".into(), r["id"].clone()); + d.fields.insert("folder".into(), json!(folder)); + update(d, &r); + app.ui.dialog_search = r["id"].as_u64(); + Ok(()) +} + +/// The files a search found for the font `f` (`{family, style}`): the answer's `fonts` row with +/// the same family and style. +fn files_for<'a>(found: &'a [Value], f: &Value) -> &'a [Value] { + found.iter().find(|r| r["family"] == f["family"] && r["style"] == f["style"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice) +} + +/// A search's answer `r` into the dialog. A font that gets its first file has that file chosen; a +/// file the user unchecked stays unchecked. +fn update(d: &mut Dialog, r: &Value) { + let before = d.fields.get("found").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default(); + for font in r["fonts"].as_array().into_iter().flatten() { + let had = !files_for(&before, font).is_empty(); + if let Some(first) = font["files"].as_array().and_then(|f| f.first()).filter(|f| !had && !chosen.contains(f)) { + chosen.push(first.clone()); + } + } + d.fields.insert("chosen".into(), json!(chosen)); + d.fields.insert("found".into(), r["fonts"].clone()); + for k in ["state", "searched", "skipped", "unreadable", "stopped", "error"] { + match r.get(k) { + Some(v) => d.fields.insert(k.into(), v.clone()), + None => d.fields.remove(k), + }; + } +} + +/// While searching: the search's progress into the dialog. A search started elsewhere since +/// stopped this one. +fn follow(app: &mut VectorcraftApp, d: &mut Dialog) { + // Polled through `session.execute`. + let Ok(r) = app.session.execute("text.findFontFiles", &json!({})) else { return }; + if d.fields.get("search") != Some(&r["id"]) { + d.fields.insert("state".into(), json!("stopped")); + return; + } + update(d, &r); + if let Some(e) = r["error"].as_str() { + app.status(e); + } +} + +/// A button of the dialog's. +enum Act { + Search(String), + Pick, + Stop, + Add, +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + if d.str("state") == "searching" { + follow(app, d); + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } + let state = d.str("state"); + let searching = state == "searching"; + widgets::dim_label(ui, tl!("These fonts aren't available. Their text shows in another font.")); + ui.add_space(8.0); + let fonts = d.fields.get("fonts").and_then(Value::as_array).cloned().unwrap_or_default(); + let found = d.fields.get("found").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default(); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + for f in &fonts { + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add_space(3.0); + let (note, color) = crate::find_font::font_note(f); + let name = format!("{} {}{note}", f["family"].as_str().unwrap_or_default(), f["style"].as_str().unwrap_or_default()); + ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(color.unwrap_or(t.text_strong))); + let files = files_for(&found, f); + for file in files { + let path = file.as_str().unwrap_or_default(); + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()); + let on = chosen.contains(file); + if widgets::check(ui, &name, on, true) { + if on { + chosen.retain(|c| c != file); + } else { + chosen.push(file.clone()); + } + } + ui.add(egui::Label::new(egui::RichText::new(path).size(11.0).color(t.text_dim)).truncate()).on_hover_text(path); + } + if files.is_empty() && !state.is_empty() { + widgets::dim_label(ui, if searching { tl!("Searching…") } else { tl!("Not found") }); + } + ui.add_space(3.0); + }); + }); + } + }); + }); + d.fields.insert("chosen".into(), json!(chosen)); + ui.add_space(10.0); + let mut act = None; + let can_pick = crate::picks::can(app, &PickRequest::Folder); + ui.horizontal(|ui| { + let next = d.str("fontsNextToDocument"); + if !next.is_empty() && ui.add_enabled(!searching, egui::Button::new(tl!("Search Fonts Folder"))).on_hover_text(&next).clicked() { + act = Some(Act::Search(next)); + } + if can_pick && ui.add_enabled(!searching, egui::Button::new(tl!("Find in Folder…"))).clicked() { + act = Some(Act::Pick); + } + if searching { + if ui.button(tl!("Stop")).clicked() { + act = Some(Act::Stop); + } + ui.add(egui::Spinner::new().size(12.0).color(t.accent)); + } + }); + if searching { + widgets::dim_name(ui, &d.str("folder")); + } + if let Some(s) = d.fields.get("searched") { + let (folders, files) = (s["folders"].to_string(), s["files"].to_string()); + widgets::dim_name( + ui, + &crate::i18n::fmt(tl!("{folders} folder(s) and {files} file(s) searched"), &[("folders", &folders), ("files", &files)]), + ); + } + let count = |k: &str| d.fields.get(k).and_then(Value::as_u64).filter(|n| *n > 0).map(|n| n.to_string()); + if let Some(n) = count("skipped") { + let text = tl!("{count} folder(s) skipped, such as other apps' and the system's folders and hidden ones"); + widgets::dim_name(ui, &crate::i18n::fmt(text, &[("count", &n)])); + } + if let Some(n) = count("unreadable") { + widgets::dim_name(ui, &crate::i18n::fmt(tl!("{count} folder(s) or file(s) couldn't be read"), &[("count", &n)])); + } + if matches!(d.fields.get("stopped").and_then(Value::as_str), Some("time" | "limit")) { + widgets::dim_label(ui, tl!("The search stopped at its limit: search a smaller folder.")); + } + // Once files are found: what copying them means, and Add Fonts. + if found.iter().any(|f| f["files"].as_array().is_some_and(|files| !files.is_empty())) { + ui.add_space(8.0); + widgets::dim_label(ui, tl!("Copy only fonts you own or are licensed to install.")); + if let Some(dir) = vectorcraft_text::app_font_dir() { + widgets::dim_name(ui, &crate::i18n::fmt(tl!("VectorCraft copies the fonts to {folder}."), &[("folder", &dir.to_string_lossy())])); + } + ui.add_space(6.0); + if ui.add_enabled(!searching && !chosen.is_empty(), egui::Button::new(tl!("Add Fonts"))).clicked() { + act = Some(Act::Add); + } + } + let r = match act { + Some(Act::Search(folder)) => start_search(app, d, &folder), + Some(Act::Pick) => crate::picks::in_dialog(app, d, |app, d| match crate::picks::folder(app) { + Some(folder) => start_search(app, d, &folder), + None => Ok(()), + }), + Some(Act::Stop) => app.run("text.findFontFiles", json!({ "stop": true })).map(|_| follow(app, d)), + // Added: the dialog closes. + Some(Act::Add) => match add_fonts(app, d) { + Ok(_) => return true, + Err(e) => Err(e), + }, + None => Ok(()), + }; + if let Err(e) = r { + app.status(e); + } + false +} + +/// Find Fonts (`discard`): Type › Find Font on the first font the document misses, to replace it; +/// this Missing Fonts dialog doesn't come back. Otherwise (agents), with `searchFolder`: search that +/// folder, and stay open; else Add Fonts. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool("discard") { + app.ui.fonts_dialog = None; + // Find Font works on the active document; the dialog's may be another tab. + let uid = d.fields.get("document").and_then(Value::as_u64); + let index = app.session.documents().iter().position(|doc| Some(doc.uid) == uid).ok_or("that document is no longer open")?; + app.run("document.activate", json!({ "index": index }))?; + crate::find_font::open_on_missing(app); + return Ok(Value::Null); + } + let folder = d.str("searchFolder"); + if !folder.is_empty() { + let mut d = d.clone(); + d.fields.remove("searchFolder"); + // The folder is searched once: a search that doesn't start leaves the dialog without it. + if let Some(open) = app.ui.dialog.as_mut().filter(|open| open.kind == KIND) { + open.fields.remove("searchFolder"); + } + start_search(app, &mut d, &folder)?; + let state = d.fields.get("state").cloned().unwrap_or(Value::Null); + app.ui.dialog = Some(d); + return Ok(json!({ "state": state })); + } + add_fonts(app, d) +} + +/// Add Fonts: copy the chosen files into VectorCraft's Fonts folder and close. +fn add_fonts(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let chosen = d.fields.get("chosen").and_then(Value::as_array).cloned().unwrap_or_default(); + if chosen.is_empty() { + return Err("choose the font files to add".into()); + } + let r = app.run("text.addFontFiles", json!({ "files": chosen }))?; + app.ui.dialog = None; + let len = |k: &str| r[k].as_array().map_or(0, Vec::len); + let n = len("copied") + len("kept"); + let folder = r["folder"].as_str().unwrap_or_default(); + match r["skipped"].as_array().and_then(|s| s.first()) { + None => app.status(format!("{n} font file(s) are now in {folder}")), + Some(first) => { + let failed = len("skipped"); + let file = + first["file"].as_str().map(std::path::Path::new).and_then(std::path::Path::file_name).map(|f| f.to_string_lossy().into_owned()); + let reason = format!("{}: {}", file.unwrap_or_default(), first["reason"].as_str().unwrap_or_default()); + app.status(format!("{n} font file(s) are now in {folder}; {failed} couldn't be added: {reason}")); + } + } + Ok(r) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::Instant; + + use vectorcraft_engine::Session; + use vectorcraft_engine::cmd::findfiles::Rules; + + use super::*; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + /// A fresh temporary folder (canonical, as a search reports it). + fn folder(tag: &str) -> PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) + } + + /// An app that reads files and whose searches may go anywhere in `root`, on two threads. + fn app_for(root: &Path) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.session.search_rules = Some(Rules { user_content: vec![root.to_path_buf()], ..Rules::default() }); + app.session.search_threads = Some(2); + app + } + + fn png() -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(40, 30, image::Rgba([200, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// Save a new document with type in `font` at `path`, with a linked image that is then deleted + /// when `link`. + fn save_doc(app: &mut VectorcraftApp, path: &Path, font: &str, link: bool) { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let pic = path.with_extension("png"); + if link { + std::fs::write(&pic, png()).unwrap(); + app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap(); + } + app.run("text.create", json!({"x": 10, "y": 40, "text": "Type", "font": font})).unwrap(); + app.run("document.save", json!({"path": path.to_string_lossy()})).unwrap(); + if link { + std::fs::remove_file(&pic).unwrap(); + } + } + + fn open(app: &mut VectorcraftApp, path: &Path) { + crate::io::open_path(app, &path.to_string_lossy()).unwrap(); + } + + fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) + } + + #[test] + fn missing_fonts_are_asked_about_after_the_missing_links() { + let root = folder("after-links"); + let mut app = app_for(&root); + let doc = root.join("doc.vectorcraft"); + save_doc(&mut app, &doc, "No Such Font Family", true); + open(&mut app, &doc); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + super::super::cancel(&mut app); + let d = app.ui.dialog.as_ref().expect("Missing Fonts after the linked files"); + assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("No Such Font Family"))); + let text = frame(&mut app); + for label in + ["Missing Fonts", "No Such Font Family Regular", "These fonts aren't available. Their text shows in another font.", "Find Fonts", "Close"] + { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("Search Fonts Folder"), "no Fonts folder next to the document"); + assert!(!text.contains("Add Fonts") && !text.contains("Copy only fonts"), "no file found yet: {text}"); + assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); + assert_eq!(kind(&app), Some(KIND), "nothing to add: the dialog stays open"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() { + let root = folder("package"); + let font = root.join("pkg/Fonts/sub/Findme.otf"); + std::fs::create_dir_all(font.parent().unwrap()).unwrap(); + std::fs::write(&font, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); + let mut app = app_for(&root); + let doc = root.join("pkg/doc.vectorcraft"); + save_doc(&mut app, &doc, "Findme Sans 3", false); + open(&mut app, &doc); + let next = app.ui.dialog.as_ref().filter(|d| d.kind == KIND).expect("Missing Fonts at once: no links missing").str("fontsNextToDocument"); + assert_eq!(next, root.join("pkg/Fonts").to_string_lossy()); + assert!(frame(&mut app).contains("Search Fonts Folder")); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(next)); + let r = super::super::confirm(&mut app).unwrap(); + assert!(matches!(r["state"].as_str(), Some("searching" | "done")), "{r}"); + assert_eq!(kind(&app), Some(KIND), "the dialog stays open while it searches"); + let until = Instant::now() + Duration::from_secs(10); + while app.ui.dialog.as_ref().is_some_and(|d| d.str("state") == "searching") && Instant::now() < until { + frame(&mut app); + std::thread::sleep(Duration::from_millis(5)); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("state"), &d.fields["found"][0]["files"]), ("done".to_string(), &json!([font]))); + assert_eq!(d.fields["chosen"], json!([font]), "the font's file is chosen"); + let text = frame(&mut app); + for label in ["Findme.otf", "Copy only fonts you own or are licensed to install.", "Add Fonts"] { + assert!(text.contains(label), "{label} in {text}"); + } + // Adding needs VectorCraft's Fonts folder, which only the apps set. + let e = super::super::confirm(&mut app).unwrap_err(); + assert!(e.contains("isn't set here"), "{e}"); + assert_eq!(kind(&app), Some(KIND)); + } + + /// Find Fonts opens Type › Find Font on the first missing font, to replace it, and the Missing + /// Fonts dialog doesn't come back for that open. + #[test] + fn find_fonts_opens_find_font_on_the_missing_font() { + let root = folder("find-fonts"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Here", "font": "Source Sans 3"})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "Zz Missing Family"})).unwrap(); + let doc = root.join("doc.vectorcraft"); + app.run("document.save", json!({"path": doc.to_string_lossy()})).unwrap(); + open(&mut app, &doc); + assert_eq!(kind(&app), Some(KIND)); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + let d = app.ui.dialog.as_ref().expect("Find Font"); + assert_eq!(d.kind, "findFont"); + let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); + let selected = d.fields["selected"].as_u64().unwrap() as usize; + assert!(selected > 0 && fonts[selected]["family"] == "Zz Missing Family", "{selected} in {fonts}"); + // Find Font closed: no Missing Fonts dialog follows. + app.ui.dialog = None; + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); + } + + /// The dialog of a document in another tab: Find Fonts makes that document the active one, so + /// Find Font lists and replaces its fonts. + #[test] + fn find_fonts_works_on_the_dialog_s_own_document() { + let root = folder("find-fonts-tab"); + let mut app = app_for(&root); + let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); + save_doc(&mut app, &a, "Alpha Missing Family", false); + save_doc(&mut app, &b, "Beta Missing Family", true); + open(&mut app, &a); + open(&mut app, &b); + // B's linked files are asked about first; then A's fonts, with B the active tab. + super::super::cancel(&mut app); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["fonts"][0]["family"], "Alpha Missing Family"); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*b.to_string_lossy())); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*a.to_string_lossy())); + let fonts = app.session.execute("text.fonts", &json!({})).unwrap(); + let selected = app.ui.dialog.as_ref().unwrap().fields["selected"].as_u64().unwrap() as usize; + assert_eq!(fonts[selected]["family"], "Alpha Missing Family", "{fonts}"); + } + + #[test] + fn closing_the_dialog_stops_its_search() { + let root = folder("stop"); + // Enough folders that the search usually still runs when the dialog closes. + for a in 0..40 { + for b in 0..40 { + std::fs::create_dir_all(root.join(format!("f{a}/g{b}"))).unwrap(); + } + } + let mut app = app_for(&root); + app.session.search_threads = Some(1); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap(); + let id = app.ui.dialog_search.expect("the dialog's search"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none()); + // Stopped by the close, unless the walker finished the folders first. + let r = app.session.execute("text.findFontFiles", &json!({})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(id), "{r}"); + match r["state"].as_str() { + Some("stopped") => assert_eq!(r["stopped"], "stop", "{r}"), + state => assert_eq!(state, Some("done"), "{r}"), + } + // Without missing fonts there is nothing to open. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + assert_eq!(app.run("ui.missingFontsDialog", json!({})).err().as_deref(), Some("No fonts are missing in this document")); + } + + #[test] + fn one_dialog_at_a_time_document_by_document() { + let root = folder("queue"); + let mut app = app_for(&root); + let (a, b) = (root.join("a.vectorcraft"), root.join("b.vectorcraft")); + save_doc(&mut app, &a, "Alpha Missing Family", false); + save_doc(&mut app, &b, "Beta Missing Family", true); + open(&mut app, &a); + let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().and_then(|d| d.fields["fonts"][0]["family"].as_str().map(str::to_string)); + assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); + // The second document's linked files are asked about first; then each document's fonts. + open(&mut app, &b); + assert_eq!(kind(&app), Some(super::super::missing_links::KIND)); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Alpha Missing Family")); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + // A document closed before its turn is passed over. + open(&mut app, &a); + open(&mut app, &b); + let index = app.session.documents().iter().rposition(|d| d.path.as_deref() == Some(&*a.to_string_lossy())).unwrap(); + app.session.execute("file.close", &json!({"index": index})).unwrap(); + super::super::cancel(&mut app); + assert_eq!(family(&app).as_deref(), Some("Beta Missing Family")); + } + + /// Another dialog that takes the Missing Fonts dialog's place (Import PDF for a PDF dropped + /// with the document) hands the question back once it closes. + #[test] + fn the_question_comes_back_after_a_dialog_that_took_its_place() { + let root = folder("replaced"); + let mut app = app_for(&root); + let doc = root.join("a.vectorcraft"); + save_doc(&mut app, &doc, "Alpha Missing Family", false); + let pdf = || { + let page = || vectorcraft_testkit::pdf::PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"); + vectorcraft_testkit::pdf::pdf(&[page(), page()], None) + }; + let opened = |name: &str, path: Option, bytes: Vec| (crate::place::DropTarget::Open, (name.to_string(), path, bytes)); + let files = + vec![opened("a.vectorcraft", Some(doc.to_string_lossy().into_owned()), std::fs::read(&doc).unwrap()), opened("b.pdf", None, pdf())]; + crate::place::drop_files(&mut app, files); + assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); + super::super::cancel(&mut app); + let family = |app: &VectorcraftApp| app.ui.dialog.as_ref().filter(|d| d.kind == KIND).map(|d| d.fields["fonts"][0]["family"].clone()); + assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); + // The same when a frame drew the other dialog before it closed. + crate::io::open_bytes(&mut app, "c.pdf", &pdf(), None).unwrap(); + assert_eq!(kind(&app), Some(super::super::import_pdf::KIND)); + frame(&mut app); + super::super::cancel(&mut app); + assert_eq!(family(&app), Some(json!("Alpha Missing Family"))); + // Closed by Cancel, or by Escape (which empties the dialog slot), it stays closed. + super::super::cancel(&mut app); + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); + app.run("ui.missingFontsDialog", json!({})).unwrap(); + app.ui.dialog = None; + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); + } + + /// Type in a CSS generic family (`sans-serif`, the default font of SVG files from some editors) + /// opens without the question. + #[test] + fn generic_families_are_not_asked_about() { + let mut app = app_for(&folder("generic")); + let svg = r#"A"#; + crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap(); + let listed = app.session.execute("text.fonts", &json!({})).unwrap(); + assert!(listed.as_array().unwrap().iter().any(|f| f["family"] == "sans-serif"), "{listed}"); + frame(&mut app); + assert!(app.ui.dialog.is_none() && app.ui.pending_fonts.is_empty()); + } + + /// A `searchFolder` whose search doesn't start is dropped from the dialog: OK doesn't try it + /// again. + #[test] + fn a_search_folder_that_fails_is_not_tried_again() { + let root = folder("retry"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + app.run("ui.missingFontsDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!("relative/folder")); + let e = super::super::confirm(&mut app).unwrap_err(); + assert!(e.contains("absolute"), "{e}"); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("searchFolder")); + assert_eq!(super::super::confirm(&mut app).err().as_deref(), Some("choose the font files to add")); + } + + /// A placed document that Links › Edit Original opens asks about its fonts, as File › Open does. + #[test] + fn edit_original_asks_about_the_placed_documents_fonts() { + let root = folder("edit-original"); + let mut app = app_for(&root); + let badge = root.join("badge.vectorcraft"); + save_doc(&mut app, &badge, "Badge Missing Family", false); + app.session.execute("file.close", &json!({})).unwrap(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("file.place", json!({"path": badge.to_string_lossy(), "link": true})).unwrap(); + let tabs = app.session.documents().len(); + app.run("links.editOriginal", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), tabs + 1); + let d = app.ui.dialog.as_ref().expect("Missing Fonts for the document opened"); + assert_eq!((d.kind.as_str(), d.fields["fonts"][0]["family"].as_str()), (KIND, Some("Badge Missing Family"))); + } + + /// A search leaves out the fonts that became available: the files it found go with their fonts + /// by name, and a file the user unchecked stays unchecked as the search goes on. + #[test] + fn files_go_with_their_fonts_by_name() { + let (a, b) = (json!({"family": "A Sans", "style": "Regular"}), json!({"family": "B Sans", "style": "Bold"})); + let mut d = Dialog::new(KIND, json!({"fonts": [a, b], "chosen": []})); + update(&mut d, &json!({"state": "searching", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf"]}]})); + let found = d.fields["found"].as_array().unwrap().clone(); + assert!(files_for(&found, &a).is_empty()); + assert_eq!(files_for(&found, &b), [json!("/f/b.otf")]); + assert_eq!(d.fields["chosen"], json!(["/f/b.otf"])); + d.fields.insert("chosen".into(), json!([])); + update(&mut d, &json!({"state": "done", "fonts": [{"family": "B Sans", "style": "Bold", "files": ["/f/b.otf", "/g/b.otf"]}]})); + assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([]))); + } + + /// The status lines are translated with the reasons in them, and so are the reasons a search + /// failed and a file wasn't copied, which the code holds as constants. + #[test] + fn the_status_lines_read_in_the_interface_language() { + let fr = crate::i18n::Lang::from_code("fr").unwrap(); + let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB"); + assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}"); + for code in ["es", "fr", "it", "ru"] { + let lang = crate::i18n::Lang::from_code(code).unwrap(); + for s in [ + "every search thread stopped on an internal error (please report this bug)", + "not a file", + "not a font file", + "the files come to more than 1 GB: add fewer at a time", + "every numbered name for it is taken in the Fonts folder", + ] { + assert_ne!(crate::i18n::message(lang, s), s, "{code}"); + } + } + } + + #[test] + fn the_web_says_how_many_fonts_are_missing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); + after_open_for(&mut app, &json!({}), true); + assert_eq!(app.ui.status, "1 font(s) aren't available here: their text shows in another font"); + assert!(app.ui.pending_fonts.is_empty()); + // The open's notes stay on show. + app.ui.status = "Opened with 1 note(s): fonts".into(); + after_open_for(&mut app, &json!({"warnings": ["fonts"]}), true); + assert_eq!(app.ui.status, "Opened with 1 note(s): fonts"); + } +} diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 2d7b3c7e..467de82e 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -35,18 +35,23 @@ fn ids(rows: &[Value]) -> Vec { } /// After a document opened (`document.open`'s result `r`): ask about its missing linked files, -/// then about its modified ones. The web reads no linked files, so missing ones just show their +/// then about its modified ones, then about the fonts it opened without +/// ([`super::missing_fonts`]). The web reads no linked files, so missing ones just show their /// previews there. pub fn after_open(app: &mut VectorcraftApp, r: &Value) { + // The fonts' dialog waits for these questions (one dialog at a time). + super::missing_fonts::after_open(app, r); let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default(); let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks"))); if missing.is_empty() || cfg!(target_arch = "wasm32") { if !missing.is_empty() { app.status(format!("{} linked file(s) can't be read here: their previews show", missing.len())); } - return ask_update(app, modified); + ask_update(app, modified); + } else { + open(app, missing, modified); } - open(app, missing, modified); + super::settle(app); } fn open(app: &mut VectorcraftApp, missing: Vec, modified: Vec) { diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 3906ff85..c1ecc219 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -39,6 +39,7 @@ pub mod import_pdf; pub mod layer_options; pub mod layers_panel_options; pub mod liquify; +pub mod missing_fonts; pub mod missing_links; pub(crate) mod modal; pub mod new_color_group; @@ -246,6 +247,7 @@ registry! { FileInfo: [file_info::KIND] => file_info::SPEC, RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, MissingLinks: [missing_links::KIND] => missing_links::SPEC, + MissingFonts: [missing_fonts::KIND] => missing_fonts::SPEC, TextImport: [text_import::KIND] => text_import::SPEC, PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC, PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC, @@ -310,20 +312,35 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes /// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`). pub fn cancel(app: &mut VectorcraftApp) { + missing_fonts::requeue_replaced(app); if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) { let _ = app.session.cancel_interaction(); } + settle(app); } /// Apply the open dialog (OK). pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { + missing_fonts::requeue_replaced(app); let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; // A file dialog it shows off the UI thread confirms the dialog as it is again. - crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d)) + let r = crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d)); + settle(app); + r +} + +/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits +/// for its turn again, the search a Missing Fonts dialog started stops once that dialog is gone, +/// and with no dialog open the next document's Missing Fonts dialog opens (one dialog at a time). +pub(crate) fn settle(app: &mut VectorcraftApp) { + missing_fonts::requeue_replaced(app); + missing_fonts::stop_when_closed(app); + missing_fonts::open_next(app); } pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); + settle(app); // The kind of dialog shown last frame: a different one (or none) means this one just opened, and // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 48dacaa0..9524096d 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -1,5 +1,6 @@ //! Type → Find Font…: the fonts the document uses (missing ones marked), Find (select the text -//! using a font), and Change / Change All to another installed font. +//! using a font), Change / Change All to another installed font, and Find in Folder… (the missing +//! fonts' files, in the Missing Fonts dialog: `ui.findFontsInFolder`). use egui::Ui; use serde_json::{Value, json}; @@ -11,7 +12,19 @@ use crate::{VectorcraftApp, font_menu}; /// Open the dialog. pub fn open(app: &mut VectorcraftApp) { - app.ui.dialog = Some(Dialog::new("findFont", json!({ "selected": 0, "family": "Source Sans 3", "style": "", "selectionOnly": false }))); + open_at(app, 0); +} + +/// Open the dialog on the first font the document misses, to replace it (Missing Fonts' Find +/// Fonts). +pub fn open_on_missing(app: &mut VectorcraftApp) { + let first = fonts(app).iter().position(|f| matches!(f["status"].as_str(), Some("missing" | "substitute"))); + open_at(app, first.unwrap_or(0)); +} + +/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen. +fn open_at(app: &mut VectorcraftApp, selected: usize) { + app.ui.dialog = Some(Dialog::new("findFont", json!({ "selected": selected, "family": "Source Sans 3", "style": "", "selectionOnly": false }))); } fn fonts(app: &mut VectorcraftApp) -> Vec { @@ -35,7 +48,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let list = fonts(app); let sample = font_menu::sample_text(app); + // Find in Folder… shows when the document misses fonts, fonts used only in its symbols included. + let offer_folder = crate::picks::can(app, &crate::picks::PickRequest::Folder) + && app.session.active().is_some_and(|st| !vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).is_empty()); let mut close = false; + let mut find_in_folder = false; let mut act: Option<&str> = None; crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| { ui.set_width(380.0); @@ -57,6 +74,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); }); + // The missing fonts' files, looked for in a folder (the Missing Fonts dialog). + if offer_folder { + ui.add_space(6.0); + find_in_folder = ui.button(tl!("Find in Folder…")).clicked(); + } ui.add_space(10.0); widgets::subheader(ui, tl!("Replace With Font")); let fam = d.str("family"); @@ -113,11 +135,15 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } else if app.ui.dialog.is_some() { app.ui.dialog = Some(d); } + // Missing Fonts takes the dialog's place, searching the folder picked. + if find_in_folder && let Err(e) = app.run("ui.findFontsInFolder", json!({})) { + app.ui.status = e; + } } /// What a Find Font row says after the font's name (`text.fonts` row): a missing family, a style /// standing in for another, characters the font lacks; and the colour to flag it with. -fn font_note(f: &Value) -> (String, Option) { +pub(crate) fn font_note(f: &Value) -> (String, Option) { let lacking = f["missingGlyphs"].as_u64().unwrap_or(0); let glyphs = if lacking > 0 { crate::i18n::fmt(tl!(" — {n} characters from another font"), &[("n", &lacking.to_string())]) } else { String::new() }; @@ -156,6 +182,33 @@ mod tests { assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3"); } + #[test] + fn find_in_folder_puts_the_missing_fonts_dialog_in_find_fonts_place() { + let dir = std::fs::canonicalize(vectorcraft_testkit::temp_dir("find-font-folder")).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.search_rules = Some(vectorcraft_engine::cmd::findfiles::Rules { user_content: vec![dir.clone()], ..Default::default() }); + app.session.search_threads = Some(1); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.findFontsInFolder"), "no folder picker (the web)"); + let picked = dir.to_string_lossy().into_owned(); + app.services.pick_folder = Some(Box::new(move || Some(picked.clone()))); + assert!(crate::menus::enabled(&app, "ui.findFontsInFolder")); + // Nothing missing: no button, and no folder is asked for. + open(&mut app); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Fonts in Document") && !text.contains("Find in Folder…"), "{text}"); + assert_eq!(app.run("ui.findFontsInFolder", json!({})).err().as_deref(), Some("No fonts are missing in this document")); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Find in Folder…"), "{text}"); + app.run("ui.findFontsInFolder", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("folder")), (crate::dialogs::missing_fonts::KIND, dir.to_string_lossy().into_owned())); + assert_eq!(d.fields["fonts"][0]["family"], "Missing Family"); + assert!(app.ui.dialog_search.is_some()); + crate::dialogs::cancel(&mut app); + } + #[test] fn rows_flag_missing_fonts_substituted_styles_and_lacking_characters() { let row = |status: &str, glyphs: u64| json!({"status": status, "resolved": {"style": "W4"}, "missingGlyphs": glyphs}); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62677621..57d405c9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2062,6 +2062,25 @@ Reflect… Reflejar… Refresh Actualizar Refresh Font List Actualizar lista de fuentes + Missing Fonts Fuentes que faltan + Missing Fonts Dialog Cuadro de diálogo Fuentes que faltan + These fonts aren't available. Their text shows in another font. Estas fuentes no están disponibles. Su texto se muestra con otra fuente. + Search Fonts Folder Buscar en la carpeta Fonts + Find in Folder… Buscar en una carpeta… + Find Fonts in Folder… Buscar fuentes en una carpeta… + Find Font Files Buscar archivos de fuentes + Add Font Files Agregar archivos de fuentes + Add Fonts Agregar fuentes + Find Fonts Buscar fuentes + Stop Detener + Searching… Buscando… + Not found No se encontró + {folders} folder(s) and {files} file(s) searched Se buscó en {folders} carpeta(s) y {files} archivo(s) + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Se omitieron {count} carpeta(s), como las de otras aplicaciones y del sistema y las ocultas + {count} folder(s) or file(s) couldn't be read No se pudieron leer {count} carpeta(s) o archivo(s) + The search stopped at its limit: search a smaller folder. La búsqueda se detuvo en su límite: busca en una carpeta más pequeña. + Copy only fonts you own or are licensed to install. Copia solo fuentes que te pertenezcan o cuya licencia te permita instalarlas. + VectorCraft copies the fonts to {folder}. VectorCraft copia las fuentes en {folder}. Registered Trademark Symbol Símbolo de marca registrada Registration marks Marcas de registro Registry name: Nombre del registro: @@ -3714,6 +3733,24 @@ axis Both Ambos @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos @msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why}) +@msg No fonts are missing in this document No faltan fuentes en este documento +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft no busca en carpetas de otras aplicaciones ni del sistema: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft no busca en las carpetas Shared ni Public de Users: {path} +@msg {path} is not a folder {path} no es una carpeta +@msg the search couldn't start: try again in a moment no se pudo iniciar la búsqueda: inténtalo de nuevo en un momento +@msg searching a folder isn't available on the web en la web no se puede buscar en una carpeta +@msg every search thread stopped on an internal error (please report this bug) todos los hilos de búsqueda se detuvieron por un error interno (informa de este error) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it La carpeta Fonts de VectorCraft no está configurada aquí: la configuran la aplicación de escritorio y vectorcraft-cli +@msg choose the font files to add elige los archivos de fuentes que quieres agregar +@msg {n} font file(s) are now in {folder} {n} archivo(s) de fuentes ahora están en {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} archivo(s) de fuentes ahora están en {folder}; no se pudieron agregar {failed}: {reason} +@msg {n} font(s) aren't available here: their text shows in another font aquí no están disponibles {n} fuente(s): su texto se muestra con otra fuente +@msg not a file no es un archivo +@msg not a font file no es un archivo de fuente +@msg that document is no longer open ese documento ya no está abierto +@msg larger than 256 MB mayor de 256 MB +@msg the files come to more than 1 GB: add fewer at a time los archivos suman más de 1 GB: agrega menos a la vez +@msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4faafcb7..45d68a1f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2059,6 +2059,25 @@ Reflect… Miroir… Refresh Actualiser Refresh Font List Actualiser la liste des polices + Missing Fonts Polices manquantes + Missing Fonts Dialog Boîte de dialogue Polices manquantes + These fonts aren't available. Their text shows in another font. Ces polices ne sont pas disponibles. Leur texte s’affiche dans une autre police. + Search Fonts Folder Rechercher dans le dossier Fonts + Find in Folder… Rechercher dans un dossier… + Find Fonts in Folder… Rechercher des polices dans un dossier… + Find Font Files Rechercher des fichiers de polices + Add Font Files Ajouter des fichiers de polices + Add Fonts Ajouter les polices + Find Fonts Rechercher les polices + Stop Arrêter + Searching… Recherche… + Not found Introuvable + {folders} folder(s) and {files} file(s) searched {folders} dossier(s) et {files} fichier(s) parcourus + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} dossier(s) ignoré(s), comme ceux d’autres apps et du système et les dossiers masqués + {count} folder(s) or file(s) couldn't be read {count} dossier(s) ou fichier(s) illisible(s) + The search stopped at its limit: search a smaller folder. La recherche s’est arrêtée à sa limite : recherchez dans un dossier plus petit. + Copy only fonts you own or are licensed to install. Ne copiez que des polices qui vous appartiennent ou dont la licence vous autorise l’installation. + VectorCraft copies the fonts to {folder}. VectorCraft copie les polices dans {folder}. Registered Trademark Symbol Symbole de marque déposée Registration marks Repères de montage Registry name: Nom du registre : @@ -3711,6 +3730,24 @@ axis Both Les deux @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée @msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why}) +@msg No fonts are missing in this document Aucune police ne manque dans ce document +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft ne recherche pas dans les dossiers d’autres apps ni du système : {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft ne recherche pas dans les dossiers Shared et Public de Users : {path} +@msg {path} is not a folder {path} n’est pas un dossier +@msg the search couldn't start: try again in a moment la recherche n’a pas pu démarrer : réessayez dans un instant +@msg searching a folder isn't available on the web la recherche dans un dossier n’est pas disponible sur le web +@msg every search thread stopped on an internal error (please report this bug) tous les fils de recherche se sont arrêtés sur une erreur interne (veuillez signaler ce bogue) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it Le dossier Fonts de VectorCraft n’est pas défini ici : l’application de bureau et vectorcraft-cli le définissent +@msg choose the font files to add choisissez les fichiers de polices à ajouter +@msg {n} font file(s) are now in {folder} {n} fichier(s) de polices se trouvent maintenant dans {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} fichier(s) de polices se trouvent maintenant dans {folder} ; {failed} n’ont pas pu être ajoutés : {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} police(s) indisponible(s) ici : leur texte s’affiche dans une autre police +@msg not a file n’est pas un fichier +@msg not a font file n’est pas un fichier de police +@msg that document is no longer open ce document n’est plus ouvert +@msg larger than 256 MB plus de 256 Mo +@msg the files come to more than 1 GB: add fewer at a time les fichiers dépassent 1 Go : ajoutez-en moins à la fois +@msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6532854a..ada181c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2062,6 +2062,25 @@ Reflect… Rifletti… Refresh Aggiorna Refresh Font List Aggiorna elenco font + Missing Fonts Font mancanti + Missing Fonts Dialog Finestra di dialogo Font mancanti + These fonts aren't available. Their text shows in another font. Questi font non sono disponibili. Il loro testo viene mostrato con un altro font. + Search Fonts Folder Cerca nella cartella Fonts + Find in Folder… Trova in una cartella… + Find Fonts in Folder… Trova font in una cartella… + Find Font Files Trova file dei font + Add Font Files Aggiungi file dei font + Add Fonts Aggiungi font + Find Fonts Trova font + Stop Interrompi + Searching… Ricerca in corso… + Not found Non trovato + {folders} folder(s) and {files} file(s) searched Cartelle cercate: {folders}, file: {files} + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Cartelle saltate: {count}, ad esempio quelle di altre app e di sistema e quelle nascoste + {count} folder(s) or file(s) couldn't be read Cartelle o file non leggibili: {count} + The search stopped at its limit: search a smaller folder. La ricerca si è fermata al suo limite: cerca in una cartella più piccola. + Copy only fonts you own or are licensed to install. Copia solo font di tua proprietà o con una licenza che ti consente di installarli. + VectorCraft copies the fonts to {folder}. VectorCraft copia i font in {folder}. Registered Trademark Symbol Simbolo di marchio registrato Registration marks Segni di registro Registry name: Nome registro: @@ -3714,6 +3733,24 @@ axis Both Entrambi @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa @msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why}) +@msg No fonts are missing in this document In questo documento non mancano font +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft non cerca nelle cartelle di altre app né di sistema: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft non cerca nelle cartelle Shared e Public di Users: {path} +@msg {path} is not a folder {path} non è una cartella +@msg the search couldn't start: try again in a moment impossibile avviare la ricerca: riprova tra un momento +@msg searching a folder isn't available on the web la ricerca in una cartella non è disponibile sul web +@msg every search thread stopped on an internal error (please report this bug) tutti i thread di ricerca si sono fermati per un errore interno (segnala questo bug) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it La cartella Fonts di VectorCraft non è impostata qui: la impostano l'app desktop e vectorcraft-cli +@msg choose the font files to add scegli i file dei font da aggiungere +@msg {n} font file(s) are now in {folder} File di font ora in {folder}: {n} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} File di font ora in {folder}: {n}; impossibile aggiungerne {failed}: {reason} +@msg {n} font(s) aren't available here: their text shows in another font font non disponibili qui: {n}; il loro testo viene mostrato con un altro font +@msg not a file non è un file +@msg not a font file non è un file di font +@msg that document is no longer open quel documento non è più aperto +@msg larger than 256 MB più grande di 256 MB +@msg the files come to more than 1 GB: add fewer at a time i file superano 1 GB: aggiungine meno alla volta +@msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fa38398c..ec05ce82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2064,6 +2064,25 @@ Reflect Y Y 軸でリフレクト Refresh 更新 Refresh Font List フォントリストを更新 + Missing Fonts 見つからないフォント + Missing Fonts Dialog 見つからないフォントダイアログ + These fonts aren't available. Their text shows in another font. これらのフォントは使用できません。テキストは別のフォントで表示されます。 + Search Fonts Folder 「Fonts」フォルダーを検索 + Find in Folder… フォルダー内を検索… + Find Fonts in Folder… フォルダー内のフォントを検索… + Find Font Files フォントファイルを検索 + Add Font Files フォントファイルを追加 + Add Fonts フォントを追加 + Find Fonts フォントを検索 + Stop 停止 + Searching… 検索中… + Not found 見つかりません + {folders} folder(s) and {files} file(s) searched {folders} 個のフォルダーと {files} 個のファイルを検索しました + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} 個のフォルダーをスキップしました(他のアプリやシステムのフォルダー、隠しフォルダーなど) + {count} folder(s) or file(s) couldn't be read {count} 個のフォルダーまたはファイルを読み込めませんでした + The search stopped at its limit: search a smaller folder. 検索は上限で停止しました。より小さなフォルダーを検索してください。 + Copy only fonts you own or are licensed to install. 所有しているフォント、またはインストールするライセンスがあるフォントだけをコピーしてください。 + VectorCraft copies the fonts to {folder}. VectorCraft はフォントを {folder} にコピーします。 Registry name: レジストリ名: Release Compound Shape 複合シェイプを解除 Release Opacity Mask 不透明マスクを解除 @@ -3726,3 +3745,21 @@ theme Appearance 外観 @msg no user workspace `{from}` ユーザーワークスペース `{from}` が見つかりません @msg unknown workspace (one of {_1}) 不明なワークスペースです ({_1} のいずれかを指定してください) @msg workspace name can't be empty ワークスペース名を入力してください +@msg No fonts are missing in this document このドキュメントには見つからないフォントはありません +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft は他のアプリやシステムのフォルダーを検索しません: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft は Users 内の Shared フォルダーと Public フォルダーを検索しません: {path} +@msg {path} is not a folder {path} はフォルダーではありません +@msg the search couldn't start: try again in a moment 検索を開始できませんでした: しばらくしてからもう一度お試しください +@msg searching a folder isn't available on the web Web 版ではフォルダーを検索できません +@msg every search thread stopped on an internal error (please report this bug) すべての検索スレッドが内部エラーで停止しました (このバグを報告してください) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it この環境では VectorCraft の Fonts フォルダーが設定されていません: デスクトップアプリと vectorcraft-cli が設定します +@msg choose the font files to add 追加するフォントファイルを選択してください +@msg {n} font file(s) are now in {folder} {n} 個のフォントファイルが {folder} に入りました +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} 個のフォントファイルが {folder} に入りましたが、{failed} 個は追加できませんでした: {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} 個のフォントはこの環境では使用できません: テキストは別のフォントで表示されます +@msg not a file ファイルではありません +@msg not a font file フォントファイルではありません +@msg that document is no longer open そのドキュメントはもう開かれていません +@msg larger than 256 MB 256 MB を超えています +@msg the files come to more than 1 GB: add fewer at a time ファイルの合計が 1 GB を超えています: 一度に追加するファイルを減らしてください +@msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 376d6e01..4f3f90a1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1819,6 +1819,25 @@ Reflect… Espelhar… Refresh Atualizar Refresh Font List Atualizar Lista de Fontes + Missing Fonts Fontes Ausentes + Missing Fonts Dialog Diálogo de Fontes Ausentes + These fonts aren't available. Their text shows in another font. Estas fontes não estão disponíveis. O texto delas aparece em outra fonte. + Search Fonts Folder Pesquisar na Pasta Fonts + Find in Folder… Localizar em uma Pasta… + Find Fonts in Folder… Localizar Fontes em uma Pasta… + Find Font Files Localizar Arquivos de Fontes + Add Font Files Adicionar Arquivos de Fontes + Add Fonts Adicionar Fontes + Find Fonts Localizar Fontes + Stop Parar + Searching… Pesquisando… + Not found Não encontrado + {folders} folder(s) and {files} file(s) searched {folders} pasta(s) e {files} arquivo(s) pesquisados + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones {count} pasta(s) ignorada(s), como as de outros aplicativos e do sistema e as ocultas + {count} folder(s) or file(s) couldn't be read {count} pasta(s) ou arquivo(s) não puderam ser lidos + The search stopped at its limit: search a smaller folder. A pesquisa parou no limite: pesquise uma pasta menor. + Copy only fonts you own or are licensed to install. Copie apenas fontes que sejam suas ou que você tenha licença para instalar. + VectorCraft copies the fonts to {folder}. O VectorCraft copia as fontes para {folder}. Registered Trademark Symbol Símbolo de Marca Registrada Registry name: Nome do registro: Release Liberar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6f88ab3e..9f8a56eb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2058,6 +2058,25 @@ Reflect… Отразить… Refresh Обновить Refresh Font List Обновить список шрифтов + Missing Fonts Отсутствующие шрифты + Missing Fonts Dialog Диалоговое окно «Отсутствующие шрифты» + These fonts aren't available. Their text shows in another font. Эти шрифты недоступны. Их текст отображается другим шрифтом. + Search Fonts Folder Искать в папке Fonts + Find in Folder… Найти в папке… + Find Fonts in Folder… Найти шрифты в папке… + Find Font Files Найти файлы шрифтов + Add Font Files Добавить файлы шрифтов + Add Fonts Добавить шрифты + Find Fonts Найти шрифты + Stop Остановить + Searching… Поиск… + Not found Не найдено + {folders} folder(s) and {files} file(s) searched Просмотрено папок: {folders}, файлов: {files} + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Пропущено папок: {count}, например папки других приложений и системы и скрытые папки + {count} folder(s) or file(s) couldn't be read Не удалось прочитать папок или файлов: {count} + The search stopped at its limit: search a smaller folder. Поиск остановился на пределе: выберите папку поменьше. + Copy only fonts you own or are licensed to install. Копируйте только шрифты, которые принадлежат вам или которые вы вправе устанавливать по лицензии. + VectorCraft copies the fonts to {folder}. VectorCraft копирует шрифты в {folder}. Registered Trademark Symbol Символ зарегистрированного товарного знака Registration marks Метки совмещения Registry name: Имя в реестре: @@ -3710,6 +3729,24 @@ axis Both Оба @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено @msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why}) +@msg No fonts are missing in this document В этом документе нет отсутствующих шрифтов +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft не ищет в папках других приложений и системы: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft не ищет в папках Shared и Public внутри Users: {path} +@msg {path} is not a folder {path} не является папкой +@msg the search couldn't start: try again in a moment не удалось начать поиск: повторите попытку чуть позже +@msg searching a folder isn't available on the web поиск в папке недоступен в веб-версии +@msg every search thread stopped on an internal error (please report this bug) все потоки поиска остановились из-за внутренней ошибки (сообщите об этой ошибке) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it Папка Fonts VectorCraft здесь не задана: её задают настольное приложение и vectorcraft-cli +@msg choose the font files to add выберите файлы шрифтов для добавления +@msg {n} font file(s) are now in {folder} Файлов шрифтов теперь в {folder}: {n} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} Файлов шрифтов теперь в {folder}: {n}; не удалось добавить {failed}: {reason} +@msg {n} font(s) aren't available here: their text shows in another font здесь недоступно шрифтов: {n}; их текст отображается другим шрифтом +@msg not a file не файл +@msg not a font file не файл шрифта +@msg that document is no longer open этот документ больше не открыт +@msg larger than 256 MB больше 256 МБ +@msg the files come to more than 1 GB: add fewer at a time файлы занимают больше 1 ГБ: добавляйте меньше за раз +@msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4d00da15..9e630bb3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2054,6 +2054,25 @@ Reflect… Відобразити… Refresh Оновити Refresh Font List Оновити список шрифтів + Missing Fonts Відсутні шрифти + Missing Fonts Dialog Діалогове вікно «Відсутні шрифти» + These fonts aren't available. Their text shows in another font. Ці шрифти недоступні. Їхній текст показано іншим шрифтом. + Search Fonts Folder Шукати в папці Fonts + Find in Folder… Знайти в папці… + Find Fonts in Folder… Знайти шрифти в папці… + Find Font Files Знайти файли шрифтів + Add Font Files Додати файли шрифтів + Add Fonts Додати шрифти + Find Fonts Знайти шрифти + Stop Зупинити + Searching… Пошук… + Not found Не знайдено + {folders} folder(s) and {files} file(s) searched Переглянуто папок: {folders}, файлів: {files} + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Пропущено папок: {count}, зокрема папки інших програм і системи та приховані папки + {count} folder(s) or file(s) couldn't be read Не вдалося прочитати папок або файлів: {count} + The search stopped at its limit: search a smaller folder. Пошук зупинився на межі: виберіть меншу папку. + Copy only fonts you own or are licensed to install. Копіюйте лише шрифти, які належать вам або які ви маєте право встановлювати за ліцензією. + VectorCraft copies the fonts to {folder}. VectorCraft копіює шрифти в {folder}. Registered Trademark Symbol Символ зареєстрованого товарного знаку Registration marks Мітки поєднання Registry name: Ім’я у реєстрі: @@ -3697,6 +3716,24 @@ axis Both Обидві @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено @msg its editing data doesn't say where its art is у даних редагування не зазначено, де знаходиться графіка @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) цей файл збережений без сумісності з PDF, і його дані редагування не вдається прочитати ({why}) +@msg No fonts are missing in this document У цьому документі немає відсутніх шрифтів +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft не шукає в папках інших програм і системи: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft не шукає в папках Shared і Public у Users: {path} +@msg {path} is not a folder {path} не є папкою +@msg the search couldn't start: try again in a moment не вдалося почати пошук: спробуйте ще раз за мить +@msg searching a folder isn't available on the web пошук у папці недоступний у веб-версії +@msg every search thread stopped on an internal error (please report this bug) усі потоки пошуку зупинилися через внутрішню помилку (повідомте про цю помилку) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it Папку Fonts VectorCraft тут не задано: її задають настільна програма та vectorcraft-cli +@msg choose the font files to add виберіть файли шрифтів для додавання +@msg {n} font file(s) are now in {folder} Файлів шрифтів тепер у {folder}: {n} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} Файлів шрифтів тепер у {folder}: {n}; не вдалося додати {failed}: {reason} +@msg {n} font(s) aren't available here: their text shows in another font тут недоступно шрифтів: {n}; їхній текст показано іншим шрифтом +@msg not a file не файл +@msg not a font file не файл шрифту +@msg that document is no longer open цей документ більше не відкрито +@msg larger than 256 MB більше 256 МБ +@msg the files come to more than 1 GB: add fewer at a time файли займають понад 1 ГБ: додавайте менше за раз +@msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1c90bf1..8d7a32a9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2016,6 +2016,25 @@ Reflect… 镜像… Refresh 刷新 Refresh Font List 刷新字体列表 + Missing Fonts 缺失的字体 + Missing Fonts Dialog 缺失的字体对话框 + These fonts aren't available. Their text shows in another font. 这些字体不可用。其文字以其他字体显示。 + Search Fonts Folder 搜索 Fonts 文件夹 + Find in Folder… 在文件夹中查找… + Find Fonts in Folder… 在文件夹中查找字体… + Find Font Files 查找字体文件 + Add Font Files 添加字体文件 + Add Fonts 添加字体 + Find Fonts 查找字体 + Stop 停止 + Searching… 正在搜索… + Not found 未找到 + {folders} folder(s) and {files} file(s) searched 已搜索 {folders} 个文件夹、{files} 个文件 + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones 已跳过 {count} 个文件夹,例如其他应用程序和系统的文件夹,以及隐藏的文件夹 + {count} folder(s) or file(s) couldn't be read 有 {count} 个文件夹或文件无法读取 + The search stopped at its limit: search a smaller folder. 搜索已达上限而停止:请搜索较小的文件夹。 + Copy only fonts you own or are licensed to install. 只复制您拥有或已获得安装许可的字体。 + VectorCraft copies the fonts to {folder}. VectorCraft 会将字体复制到 {folder}。 Registered Trademark Symbol 注册商标符号 Registration marks 对位标记 Registry name: 登录名称: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 65b10d1c..1b317374 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2065,6 +2065,25 @@ Reflect… 鏡射… Refresh 重新整理 Refresh Font List 重新整理字體清單 + Missing Fonts 遺失的字體 + Missing Fonts Dialog 遺失的字體對話框 + These fonts aren't available. Their text shows in another font. 這些字體無法使用。其文字以其他字體顯示。 + Search Fonts Folder 搜尋 Fonts 資料夾 + Find in Folder… 在資料夾中尋找… + Find Fonts in Folder… 在資料夾中尋找字體… + Find Font Files 尋找字體檔案 + Add Font Files 新增字體檔案 + Add Fonts 新增字體 + Find Fonts 尋找字體 + Stop 停止 + Searching… 搜尋中… + Not found 找不到 + {folders} folder(s) and {files} file(s) searched 已搜尋 {folders} 個資料夾、{files} 個檔案 + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones 已略過 {count} 個資料夾,例如其他應用程式和系統的資料夾,以及隱藏的資料夾 + {count} folder(s) or file(s) couldn't be read 有 {count} 個資料夾或檔案無法讀取 + The search stopped at its limit: search a smaller folder. 搜尋已達上限而停止:請搜尋較小的資料夾。 + Copy only fonts you own or are licensed to install. 只複製您擁有或已取得安裝授權的字體。 + VectorCraft copies the fonts to {folder}. VectorCraft 會將字體複製到 {folder}。 Registered Trademark Symbol 註冊商標符號 Registration marks 對位標記 Registry name: 登錄名稱: diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bcb4b0fc..e53eedf6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,6 +137,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), ("file.clearRecent", "Clear Recent Files", "", "{}"), ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), + ( + "ui.missingFontsDialog", + "Missing Fonts Dialog", + "", + "{folder?} open Missing Fonts (dialog `missingFonts`) for the fonts the active document uses that aren't available (text.missingFonts); with folder, it looks for their files there at once (text.findFontFiles). An error when no font is missing. Opening a document whose fonts are missing shows it after the missing linked file questions (dialog `missingLinks`). Disabled on the web", + ), + ( + "ui.findFontsInFolder", + "Find Fonts in Folder…", + "", + "{folder?} Type › Find Font's Find in Folder…: asks for a folder (or takes folder), then opens Missing Fonts looking there for the files of the fonts the active document misses (text.findFontFiles). Disabled where there is no folder picker (the web)", + ), ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), ( "file.export.svg", @@ -879,6 +891,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::missing_fonts::open_command(app, s("folder")), + "ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")), "file.clearRecent" => { app.ui.recent_files.clear(); Ok(Value::Null) @@ -1853,6 +1867,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.cropImage" => selected_image(app, |_| true), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), + "ui.missingFontsDialog" => !cfg!(target_arch = "wasm32") && app.session.active().is_some(), + "ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 3250b16b..7e10159d 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -185,7 +185,10 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re } // A placed document's file opens here, in a new tab: saving it updates this document. if !reveal && info["document"] == true { - app.run("document.open", json!({ "path": path }))?; + let opened = app.run("document.open", json!({ "path": path }))?; + // Its missing fonts are asked about as for a document opened with File › Open. + crate::dialogs::missing_fonts::after_open(app, &opened); + crate::dialogs::settle(app); return Ok(json!({ "path": path })); } if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cb98d4b..9c75a81f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -347,6 +347,18 @@ pub struct UiState { /// dialog is open. #[serde(skip)] pub dialog_file: Option>, + /// The documents (uids) whose Missing Fonts dialog waits until no dialog is open, the next one to + /// show first ([`crate::dialogs::settle`]). + #[serde(skip)] + pub pending_fonts: Vec, + /// The document (uid) whose Missing Fonts dialog is on show. When another dialog takes its + /// place, [`crate::dialogs::settle`] puts the document back at the front of `pending_fonts`. + #[serde(skip)] + pub fonts_dialog: Option, + /// The search the Missing Fonts dialog started (`text.findFontFiles`'s `id`), stopped once the + /// dialog is gone. + #[serde(skip)] + pub dialog_search: Option, /// The DXF Options chosen last (`document.exportDxf` options; null: never used). #[serde(default)] pub dxf_options: Value, @@ -519,6 +531,9 @@ impl Default for UiState { svg_options: Value::Null, place_link: true, dialog_file: None, + pending_fonts: vec![], + fonts_dialog: None, + dialog_search: None, dxf_options: Value::Null, eps_options: Value::Null, dxf_import: Value::Null, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2a897986..b2ee755b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -375,6 +375,27 @@ folder; `ui.dialog.set {field: "discard", value: true}` then confirm ignores it Then, with the preference `updateLinks: "askWhenModified"`, modified linked files are offered for update in a `confirm` dialog whose `ui.dialog.confirm` runs `links.update`. +Missing fonts: once the missing linked file questions are answered, or at once when there are none, `app.open` of a +document whose type uses fonts that aren't available (`text.missingFonts`) opens the `missingFonts` dialog, one document +at a time, and so does `links.editOriginal` of a placed document. When another dialog takes its place (Import PDF for a +file opened next, for example), the dialog opens again once that one closes. Fields: `document` (the document's uid), +`fonts` (`[{family, style, status, resolved}]`), `fontsNextToDocument` (the `Fonts` folder next to the document, or +empty), `state` (empty, `searching`, `done`, `stopped` or `failed`), `search` (the search's `id`), `folder`, `found` +(`text.findFontFiles`'s `fonts`, with the files found), `searched`, `skipped`, `unreadable`, `stopped`, `error` and +`chosen` (the files to add; a font's first file is chosen when the search finds it). Its buttons are Find Fonts and +Close, with Search Fonts Folder, Find in Folder… and, once files are found, Add Fonts in the dialog. +`ui.dialog.set {field: "discard", value: true}` then `ui.dialog.confirm` is Find Fonts: the dialog closes and Type › +Find Font opens (dialog `findFont`) with the first missing font selected. `ui.dialog.set {field: "searchFolder", +value}` then `ui.dialog.confirm` searches that folder (`text.findFontFiles`), keeps the dialog open and clears +`searchFolder`, also when the search doesn't start. The fields change as frames draw the dialog; `text.findFontFiles +{}` reports the search directly. `ui.dialog.confirm` without `discard` or `searchFolder` is Add Fonts: it copies the +files in `chosen` (`text.addFontFiles`) and closes the dialog. `ui.dialog.cancel` (Close) closes it and stops its +search. After Find Fonts or Close, the dialog doesn't open again for that open. +`ui.missingFontsDialog {folder?}` opens the dialog for the active document, searching `folder` when given. +`ui.findFontsInFolder {folder?}` (Find in Folder… in Type › Find Font…, shown when the document misses fonts) asks for a +folder, then opens the dialog searching it. The web shows a status line instead of the dialog, and both commands are +disabled there. + Text Import Options: placing a `.txt` file through the app (`file.place` with a file and no `text` options, the Place dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac`, `characterSet`: `unicode`/`ansi`, `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces`, `spaces`). `ui.dialog.confirm` places the file as area diff --git a/docs/mcp.md b/docs/mcp.md index 5be2e57a..bbe1f998 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2562,7 +2562,11 @@ the fonts CoreText's font manager lists outside them, such as those apps and fon folders; Linux and BSD: `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.fonts` and the XDG data folders' `fonts`, `~/.local/share/fonts` among them, and in a Flatpak sandbox the host's fonts). Faces without outlines VectorCraft draws (no `glyf`, `CFF`, `CFF2` or `VARC` table, such -as bitmap-only fonts) are left out. The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup +as bitmap-only fonts) are left out. The scan also reads the folder the `fontsFolder` preference names (Preferences › Type › +Additional Fonts Folder) with its subfolders and, in the desktop app and `vectorcraft-cli`, VectorCraft's own `Fonts` folder +next to the preferences: `~/Library/Application Support/VectorCraft/Fonts` on macOS, `%APPDATA%\VectorCraft\Fonts` on +Windows, and `$XDG_CONFIG_HOME/vectorcraft/Fonts` or `~/.config/vectorcraft/Fonts` on Linux and BSD. `text.addFontFiles` +copies fonts into that folder. The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList` lists every family available, the installed ones included, as the font menus do: without the system's hidden families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names @@ -2578,6 +2582,50 @@ comes to the front and a font folder changed meanwhile. {"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}} ``` +`text.missingFonts` lists the fonts that the active document's type uses, in its layers and its symbols, and that aren't +available as named: `{fonts: [{family, style, status, resolved}], count, fontsNextToDocument?}`. `status` is `missing` or +`substitute`, as in `text.fonts`. CSS generic families such as `sans-serif`, which no font file provides, and names +longer than 256 bytes are left out. `fontsNextToDocument` is the `Fonts` folder next to the saved document +(File › Package writes it), when there is one and a search may start there. `text.findFontFiles {folder}` looks for +the fonts' files in an absolute folder and its subfolders on separate threads and returns at once with +`state: "searching"` and an `id`; `fonts: [{family, style?}]` names other fonts to look for. `text.findFontFiles {}` +returns the last search's state with the files found so far for each font: `searching`, `done`, `stopped` (with +`stopped`: `stop`, `time` or `limit`) or `failed` (with `error`); before the first search it returns +`{state: "idle", fonts: []}`, without an `id`. `{stop: true}` stops the search. A search reads the table directories +and the `name`, `fvar` and `OS/2` tables of `.ttf`, `.otf`, `.ttc` and `.otc` files and, on macOS, of suitcase fonts +(files without an extension whose fonts are in their resource fork), and it lists a file for a font only when adding +the file makes the font resolve exactly as `text.fonts` resolves it. It ends after `maxSeconds` (60 by default, 1 to +600), at its limits on the entries listed (10,000,000), the folders waiting to be listed (1,000,000), the font files +read (10,000) and the files kept (1,000), or once every font has a file. Folders more than 64 levels below the picked +one are skipped and counted in `skipped`. + +A search does not enter app and media library packages (`.app`, `.bundle`, `.framework`, `.photoslibrary` and the like), +folders named `Program Files` (also `Program Files (x86)` and `Program Files (Arm)`), `ProgramData`, `$Recycle.Bin` or +`System Volume Information`, the system's folders at the root of a volume (a macOS, Linux, BSD or Windows root, told by +the folders it holds), the `Shared` and `Public` folders in `Users`, a home folder's `Library`, `AppData`, +`Applications` and `snap` folders and the folders in it whose names start with a dot, the app data folders in a +`Library` folder (`Application Support`, `Containers`, `Group Containers`, `Caches`, `Preferences`), or the folders the +environment names for apps and the system (such as `APPDATA`, `LOCALAPPDATA`, `ProgramFiles`, `SystemRoot` and +`XDG_CONFIG_HOME`), and a search of a folder inside one of them fails. The walk also skips other hidden folders and +folders whose contents are in the cloud, and it follows no links; a picked folder may be one of those, and a picked path +that goes through a link is searched at its target. Programs installed in other folders, such as a folder on another +drive, are searched as any other folder is. + +`text.addFontFiles {files}` copies files that the session's last search found into VectorCraft's `Fonts` folder, then +scans the fonts again, and type set in them redraws. It copies only regular `.ttf`, `.otf`, `.ttc`, `.otc`, `.woff` +and `.woff2` files and suitcase fonts (with their resource fork), at most 256 MB a file and 1 GB in all, and lists any +other file in `skipped`. It never replaces a file: a file with the same contents is kept (`kept`), and when another +file has the name, the copy gets a number (`Name 2.otf`). Copy only fonts you own or are licensed to install. `command.batch` returns before a search ends; later requests over MCP or the control channel poll +`text.findFontFiles {}`. `vectorcraft-cli run` stops the search when its last step ends. The web build has no Fonts +folder and no folder search. + +```json +{"name":"run_command","arguments":{"command":"text.missingFonts","params":{}}} +{"name":"run_command","arguments":{"command":"text.findFontFiles","params":{"folder":"/Users/me/Downloads"}}} +{"name":"run_command","arguments":{"command":"text.findFontFiles","params":{}}} +{"name":"run_command","arguments":{"command":"text.addFontFiles","params":{"files":["/Users/me/Downloads/Example/Example-Regular.otf"]}}} +``` + ## Tool options `tool.setOption` reads and sets a tool's options, headless too: `{key, value}` or `{values: {…}}` sets them on the From 7b7e10e82ebbea4607ff5898e672d550d48922a9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 13:33:48 -0700 Subject: [PATCH 41/43] Fonts: a folder search skips suitcase fonts whose resource fork is over 8 MB A folder search reads at most 8 MB of each font file. The suitcase reader reads a suitcase font's resource fork whole, up to 256 MB. Before this change, the search applied its limit to each font only after that read. One search thread could read 256 MB from a single suitcase and hold a copy of its fonts as well. The search now reads a suitcase font only when its resource fork is 8 MB or less. The font scan still reads each suitcase font's resource fork whole, up to 256 MB. Docs: docs/mcp.md. --- crates/text/src/fontdb.rs | 12 ++++++++---- crates/text/src/suitcase.rs | 4 ++-- crates/text/src/tests_sysfonts.rs | 25 +++++++++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 17fc5d58..8c9695b5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -811,11 +811,15 @@ fn file_face_names(path: &Path) -> Vec { file_face_names_within(path, MAX_FACES, u64::MAX) } -/// [`file_face_names`], reading at most `max_faces` faces and `max_read` bytes of the file in all -/// (of each font in all, for a suitcase font). +/// [`file_face_names`], reading at most `max_faces` faces and `max_read` bytes of the file in all. +/// A suitcase font's resource fork is read whole when it is at most `max_read` bytes long, and the +/// limits then apply to each of its fonts; a longer fork gives none. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn file_face_names_within(path: &Path, max_faces: u32, max_read: u64) -> Vec { if crate::suitcase::is_suitcase(path) { + if crate::suitcase::fork_len(path) > max_read { + return vec![]; + } return crate::suitcase::read_fonts(path) .into_iter() .flat_map(|font| reader_face_names_within(std::io::Cursor::new(font), max_faces, max_read)) @@ -961,8 +965,8 @@ impl WantedFonts { /// font scan reads, the file makes [`FontDb::resolve`] find each of them exactly, in one of its /// faces or a variable font's named instances. The file is read as the scan reads it (its /// table directories and its `name`, `fvar` and `OS/2` tables), at most 64 faces and 8 MB of - /// it, when it is a font file by its extension ([`is_font_file`]) or a suitcase font. Sorted; - /// none on the web. + /// it, when it is a font file by its extension ([`is_font_file`]) or a suitcase font whose + /// resource fork is at most 8 MB long. Sorted; none on the web. pub fn provided_by(&self, path: &Path) -> Vec { #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/text/src/suitcase.rs b/crates/text/src/suitcase.rs index 0de80f2a..ff06a666 100644 --- a/crates/text/src/suitcase.rs +++ b/crates/text/src/suitcase.rs @@ -78,7 +78,7 @@ fn fork_path(path: &Path) -> std::path::PathBuf { } #[cfg(target_os = "macos")] -fn fork_len(path: &Path) -> u64 { +pub(crate) fn fork_len(path: &Path) -> u64 { std::fs::metadata(fork_path(path)).map_or(0, |m| m.len()) } @@ -91,7 +91,7 @@ fn read_fork(path: &Path) -> Option> { } #[cfg(not(target_os = "macos"))] -fn fork_len(_: &Path) -> u64 { +pub(crate) fn fork_len(_: &Path) -> u64 { 0 } diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 700cc826..faebd104 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -500,6 +500,31 @@ fn a_search_reads_at_most_its_share_of_a_font_file() { assert!(wanted.provided_by(&dir.join("No Such.ttf")).is_empty()); } +/// A search reads a suitcase font only when its whole resource fork fits in the 8 MB a search reads +/// of one file. +#[cfg(target_os = "macos")] +#[test] +fn a_search_reads_a_suitcase_font_only_within_its_share() { + use crate::fontdb::file_face_names_within; + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-big-suitcase", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let suitcase = dir.join("Sysfont Sans3"); + std::fs::write(&suitcase, b"").unwrap(); + let fork = crate::suitcase::fork_of(&[renamed("SourceSans3-Regular.ttf")]); + let len = fork.len() as u64; + std::fs::write(suitcase.join("..namedfork/rsrc"), &fork).unwrap(); + assert_eq!(file_face_names_within(&suitcase, 64, len).len(), 1); + assert!(file_face_names_within(&suitcase, 64, len - 1).is_empty()); + let wanted = WantedFonts::new(&[WantedFont { family: FAMILY.into(), style: "Regular".into(), installed: None }]); + assert_eq!(wanted.provided_by(&suitcase), [0]); + // Past the 8 MB a search reads of one file. + let mut big = fork; + big.resize((8 << 20) + 1, 0); + std::fs::write(suitcase.join("..namedfork/rsrc"), big).unwrap(); + assert!(wanted.provided_by(&suitcase).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn font_files_are_told_by_their_extension() { for name in ["a/B.TTF", "c.otf", "d.Ttc", "e.otc"] { diff --git a/docs/mcp.md b/docs/mcp.md index bbe1f998..14b0e1e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2597,7 +2597,7 @@ and the `name`, `fvar` and `OS/2` tables of `.ttf`, `.otf`, `.ttc` and `.otc` fi the file makes the font resolve exactly as `text.fonts` resolves it. It ends after `maxSeconds` (60 by default, 1 to 600), at its limits on the entries listed (10,000,000), the folders waiting to be listed (1,000,000), the font files read (10,000) and the files kept (1,000), or once every font has a file. Folders more than 64 levels below the picked -one are skipped and counted in `skipped`. +one are skipped and counted in `skipped`. A search does not read a suitcase font whose resource fork is larger than 8 MB. A search does not enter app and media library packages (`.app`, `.bundle`, `.framework`, `.photoslibrary` and the like), folders named `Program Files` (also `Program Files (x86)` and `Program Files (Arm)`), `ProgramData`, `$Recycle.Bin` or From 11cf254a07088685438147bec732e65073d9ca0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 9 Oct 2026 13:33:48 -0700 Subject: [PATCH 42/43] Fonts: a folder search finds .suit suitcases and skips font files over 256 MB Add Fonts adds only files a folder search found. - macOS declares .suit as the extension of a font suitcase (com.apple.font-suitcase), and the font scan reads a suitcase whatever its name. A search considered only suitcases without an extension. It now also considers files whose extension is .suit, in any letter case, and reads one only when it is a suitcase font. - Add Fonts copies at most 256 MB a file. A search also read larger font files, and a search that found one for a font could end before it found a smaller copy. A search now skips font files over 256 MB. Docs: docs/mcp.md. --- crates/engine/src/cmd/fontfiles.rs | 16 ++++++++------ crates/engine/src/tests_fontfiles.rs | 32 ++++++++++++++++++++++++++-- docs/mcp.md | 5 +++-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index a5c86baf..b499130a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -40,8 +40,8 @@ const GENERIC_FAMILIES: &[&str] = &[ const MAX_PATH: usize = 4096; /// The most files one `text.addFontFiles` copies: as many as a search keeps. const MAX_ADD_FILES: usize = 1_000; -/// The largest font file copied, and the most bytes one `text.addFontFiles` copies. -#[cfg(not(target_arch = "wasm32"))] +/// The largest font file a search reads and `text.addFontFiles` copies, and the most bytes one +/// `text.addFontFiles` copies. pub(crate) const MAX_FONT_FILE: u64 = 256 << 20; #[cfg(not(target_arch = "wasm32"))] const MAX_ADD_BYTES: u64 = 1 << 30; @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files and, on macOS, of suitcase fonts (files without an extension whose fonts are in their resource fork), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -192,12 +192,14 @@ struct FontFiles(WantedFonts); impl Visitor for FontFiles { fn wants(&self, name: &str) -> bool { let path = Path::new(name); - // A suitcase font (macOS) often has no extension. - vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none() + // A suitcase font (macOS) has no extension, or `.suit`. + vectorcraft_text::is_font_file(path) || cfg!(target_os = "macos") && path.extension().is_none_or(|e| e.eq_ignore_ascii_case("suit")) } fn worth_reading(&self, path: &Path) -> bool { - // A file without an extension is read only when it is a suitcase font. - path.extension().is_some() || vectorcraft_text::is_suitcase(path) + // A search reads font files and suitcase fonts of at most [`MAX_FONT_FILE`] bytes, the files + // `text.addFontFiles` copies. + (vectorcraft_text::is_font_file(path) || vectorcraft_text::is_suitcase(path)) + && std::fs::metadata(path).is_ok_and(|m| m.len() <= MAX_FONT_FILE) } fn items(&self, path: &Path) -> Vec { self.0.provided_by(path) diff --git a/crates/engine/src/tests_fontfiles.rs b/crates/engine/src/tests_fontfiles.rs index 3aa1c357..079d9322 100644 --- a/crates/engine/src/tests_fontfiles.rs +++ b/crates/engine/src/tests_fontfiles.rs @@ -356,8 +356,28 @@ fn only_font_files_are_copied() { let _ = std::fs::remove_dir_all(&root); } -/// A suitcase font: a file without an extension, empty but for the fonts in its resource fork, -/// which only macOS keeps. A search finds it, and Add Fonts copies it with its fork. +/// A search reads no font file larger than Add Fonts copies, and finds a smaller copy of the font. +#[test] +fn a_search_skips_font_files_too_large_to_add() { + let root = folder("too-large"); + let (big, small) = (root.join("big/Sizeme.otf"), root.join("small/Sizeme.otf")); + let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3"); + for path in [&big, &small] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, &font).unwrap(); + } + // One byte past the limit, without writing it: the file system leaves the rest unallocated. + std::fs::File::options().write(true).open(&big).unwrap().set_len(MAX_FONT_FILE + 1).unwrap(); + let mut s = session_for(&root); + // A font no file has keeps the search going through every folder. + s.execute("text.findFontFiles", &json!({"folder": root, "fonts": [{"family": "Sizeme Sans 3"}, {"family": "No Such Sans"}]})).unwrap(); + let r = finished(&mut s); + assert_eq!(r["fonts"][0]["files"], json!([small]), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A suitcase font: a file without an extension or with `.suit`, empty but for the fonts in its +/// resource fork, which only macOS keeps. A search finds it, and Add Fonts copies it with its fork. #[cfg(target_os = "macos")] #[test] fn suitcase_fonts_are_found_and_copied_with_their_fork() { @@ -380,6 +400,14 @@ fn suitcase_fonts_are_found_and_copied_with_their_fork() { s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suitme Sans 3"}]})).unwrap(); let r = finished(&mut s); assert_eq!(r["fonts"][0]["files"], json!([found]), "{r}"); + // A suitcase with the `.suit` extension is found too, and a `.suit` file that isn't one is left out. + let suit = src.join("Suited.SUIT"); + suitcase(&suit, "Suited Sans 3"); + std::fs::write(src.join("Plain.suit"), b"text").unwrap(); + // A font no file has keeps the search going through every folder. + s.execute("text.findFontFiles", &json!({"folder": src, "fonts": [{"family": "Suited Sans 3"}, {"family": "No Such Sans"}]})).unwrap(); + let r = finished(&mut s); + assert_eq!((r["fonts"][0]["files"].clone(), r["searched"]["fontFiles"].as_u64()), (json!([suit]), Some(2)), "{r}"); let r = copy_into(std::slice::from_ref(&found), &dest); assert_eq!(r.copied, [(found.clone(), dest.join("Suitme"))]); assert!(vectorcraft_text::is_suitcase(&dest.join("Suitme"))); diff --git a/docs/mcp.md b/docs/mcp.md index 14b0e1e6..2b3bb0f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2593,11 +2593,12 @@ returns the last search's state with the files found so far for each font: `sear `stopped`: `stop`, `time` or `limit`) or `failed` (with `error`); before the first search it returns `{state: "idle", fonts: []}`, without an `id`. `{stop: true}` stops the search. A search reads the table directories and the `name`, `fvar` and `OS/2` tables of `.ttf`, `.otf`, `.ttc` and `.otc` files and, on macOS, of suitcase fonts -(files without an extension whose fonts are in their resource fork), and it lists a file for a font only when adding +(extensionless or `.suit` files whose fonts are in their resource fork), and it lists a file for a font only when adding the file makes the font resolve exactly as `text.fonts` resolves it. It ends after `maxSeconds` (60 by default, 1 to 600), at its limits on the entries listed (10,000,000), the folders waiting to be listed (1,000,000), the font files read (10,000) and the files kept (1,000), or once every font has a file. Folders more than 64 levels below the picked -one are skipped and counted in `skipped`. A search does not read a suitcase font whose resource fork is larger than 8 MB. +one are skipped and counted in `skipped`. A search does not read a font file larger than 256 MB, which +`text.addFontFiles` does not copy, or a suitcase font whose resource fork is larger than 8 MB. A search does not enter app and media library packages (`.app`, `.bundle`, `.framework`, `.photoslibrary` and the like), folders named `Program Files` (also `Program Files (x86)` and `Program Files (Arm)`), `ProgramData`, `$Recycle.Bin` or From 09a0ff23adcbec7a2b79f827b1c49e8d78ac2089 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 07:23:35 +0700 Subject: [PATCH 43/43] Review fixes for #792: build expected test paths with the platform's separator Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_fontfiles.rs | 4 ++-- crates/engine/tests/font_folder.rs | 2 +- crates/ui-egui/src/dialogs/missing_fonts.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests_fontfiles.rs b/crates/engine/src/tests_fontfiles.rs index 079d9322..72e21755 100644 --- a/crates/engine/src/tests_fontfiles.rs +++ b/crates/engine/src/tests_fontfiles.rs @@ -170,7 +170,7 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { let id = r["id"].clone(); let r = finished(&mut s); assert_eq!((&r["state"], &r["id"], r.get("stopped")), (&json!("done"), &id, None), "{r}"); - let found = root.join("x/y/Findme.otf").to_string_lossy().into_owned(); + let found = root.join("x").join("y").join("Findme.otf").to_string_lossy().into_owned(); let findme = r["fonts"].as_array().unwrap().iter().find(|f| f["family"] == "Findme Sans 3").cloned().unwrap(); assert_eq!((findme["status"].as_str(), findme["files"].clone()), (Some("missing"), json!([found]))); assert_eq!(r["searched"]["fontFiles"], 2, "the font files are read, not the text file: {r}"); @@ -360,7 +360,7 @@ fn only_font_files_are_copied() { #[test] fn a_search_skips_font_files_too_large_to_add() { let root = folder("too-large"); - let (big, small) = (root.join("big/Sizeme.otf"), root.join("small/Sizeme.otf")); + let (big, small) = (root.join("big").join("Sizeme.otf"), root.join("small").join("Sizeme.otf")); let font = vectorcraft_testkit::fonts::renamed("Sizeme Sans 3"); for path in [&big, &small] { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs index 379daa08..64581651 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -31,7 +31,7 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { let fonts = root.join("App Fonts"); vectorcraft_text::set_app_font_dir(fonts.clone()); assert!(vectorcraft_text::system_font_dirs().contains(&fonts)); - let file = root.join("Downloads/Deep/Findme.otf"); + let file = root.join("Downloads").join("Deep").join("Findme.otf"); std::fs::write(&file, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); let serif = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSerif4-Regular.ttf"); std::fs::copy(serif, root.join("other.ttf")).unwrap(); diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index 5e329f9d..8692427c 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -489,7 +489,7 @@ mod tests { #[test] fn the_documents_fonts_folder_is_offered_and_searched_off_the_ui_thread() { let root = folder("package"); - let font = root.join("pkg/Fonts/sub/Findme.otf"); + let font = root.join("pkg").join("Fonts").join("sub").join("Findme.otf"); std::fs::create_dir_all(font.parent().unwrap()).unwrap(); std::fs::write(&font, vectorcraft_testkit::fonts::renamed("Findme Sans 3")).unwrap(); let mut app = app_for(&root); @@ -497,7 +497,7 @@ mod tests { save_doc(&mut app, &doc, "Findme Sans 3", false); open(&mut app, &doc); let next = app.ui.dialog.as_ref().filter(|d| d.kind == KIND).expect("Missing Fonts at once: no links missing").str("fontsNextToDocument"); - assert_eq!(next, root.join("pkg/Fonts").to_string_lossy()); + assert_eq!(next, root.join("pkg").join("Fonts").to_string_lossy()); assert!(frame(&mut app).contains("Search Fonts Folder")); app.ui.dialog.as_mut().unwrap().fields.insert("searchFolder".into(), json!(next)); let r = super::super::confirm(&mut app).unwrap();