From 06242cd3ab7f4fddaa449a98cd9097fc23700001 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 01:18:03 +0700 Subject: [PATCH 01/74] Fix #525: Image Trace of a large photo no longer crashes the app Tracing a 2048 x 2048 photo with High Fidelity Photo made 733,000 paths and 7.8 million anchors. The selected result's outlines and anchor squares then made an egui mesh of 1.8 GB, over the GPU's largest buffer: wgpu panicked while painting and the app quit (or ran out of memory first, at 6.4 GB). - Canvas: a selection with more than 100,000 anchors shows its bounds instead of every outline and anchor, and the hover highlight skips such an object. The Contextual Task Bar and that outline read the selection's bounds from a per-revision cache (VectorcraftApp:: selection_bounds): a selected trace drew a frame in 20 ms, not 170. - Image Trace: the colour layers are traced in parallel (26 s to 1.9 s for that photo; the web traces them in turn). trace_within stops once the paths pass an anchor budget; imageTrace.make allows two million and otherwise reports the trace as too complex, with what to change. Images over 64 megapixels are refused from their header, before they are decoded. - The trace crate's messages are translated (es, it) and scanned. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/buildcmds.rs | 7 +- crates/trace/src/lib.rs | 177 ++++++++++++++++++++++------- crates/trace/src/tests.rs | 70 ++++++++++++ crates/ui-egui/src/canvas.rs | 63 +++++++++- crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/lib.rs | 19 ++++ 9 files changed, 301 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ddd8e201..a1bfb922 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-08 (after 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), 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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), 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), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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). - **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) and Italian (every `it-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish and Italian 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 and Italian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. -- **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; 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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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); 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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 and loaded as `.vcswatches`, `.gpl` or CSS. 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). 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}`). diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 668da3d0..30745fa2 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -640,6 +640,11 @@ fn trace_params(p: &Value) -> Result { serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string())) } +/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make +/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is +/// refused with advice instead (#525). +pub const MAX_TRACE_ANCHORS: usize = 2_000_000; + fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; let params = trace_params(p)?; @@ -671,7 +676,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { }; let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; - let res = tr::trace(&raster, ¶ms); + let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; // Pixel space of the decoded raster → the image object's pixel space → document. let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 8776d80f..03495b25 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -27,6 +27,8 @@ mod fit; mod mosaic; mod quantize; +use std::sync::atomic::{AtomicUsize, Ordering}; + use serde::{Deserialize, Serialize}; use vectorcraft_geom::PathData; @@ -41,8 +43,18 @@ pub enum TraceError { Decode(String), #[error("image is empty")] Empty, + #[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")] + TooLarge { width: u32, height: u32, max: u64 }, + #[error( + "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" + )] + TooComplex { max: usize }, } +/// The most pixels an image to trace may have (64 megapixels: 256 MB decoded); larger images are +/// refused instead of running out of memory. +pub const MAX_PIXELS: u64 = 64 << 20; + /// An RGBA8 raster (row-major, top row first). #[derive(Clone, Debug, PartialEq)] pub struct Raster { @@ -67,13 +79,19 @@ impl Raster { } Self { width, height, rgba } } - /// Decode PNG / JPEG / WebP / GIF bytes. + /// Decode PNG / JPEG / WebP / GIF bytes (at most [`MAX_PIXELS`], checked before decoding). pub fn decode(bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| TraceError::Decode(e.to_string()))?.to_rgba8(); - let (w, h) = img.dimensions(); + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let (w, h) = reader.into_dimensions().map_err(decode_err)?; if w == 0 || h == 0 { return Err(TraceError::Empty); } + if u64::from(w) * u64::from(h) > MAX_PIXELS { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let img = image::load_from_memory(bytes).map_err(decode_err)?.to_rgba8(); + let (w, h) = img.dimensions(); Ok(Self { width: w, height: h, rgba: img.into_raw() }) } /// Encode as PNG. @@ -262,17 +280,25 @@ fn is_white(c: [u8; 3]) -> bool { /// Trace `img` with `params`. pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { + // No trace has more than `usize::MAX` anchors. + trace_within(img, params, usize::MAX).unwrap_or_default() +} + +/// Trace `img` with `params`, giving up with [`TraceError::TooComplex`] once the paths have more +/// than `max_anchors` anchor points (a photo traced at high fidelity can make millions). The +/// colour layers are traced in parallel where threads are available. +pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { let (w, h) = (img.width as usize, img.height as usize); - if w == 0 || h == 0 { - return TraceResult::default(); + if w == 0 || h == 0 || img.rgba.len() != w * h * 4 { + return Ok(TraceResult::default()); } let mut q = quantize(img, params); denoise(&mut q.labels, w, h, params.noise as usize); // Pixel count per palette entry. let mut counts = vec![0usize; q.palette.len()]; for &l in &q.labels { - if l != TRANSPARENT { - counts[l as usize] += 1; + if let Some(c) = counts.get_mut(l as usize) { + *c += 1; } } // Layers bottom → top: largest area first. @@ -282,46 +308,117 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { for (r, &i) in order.iter().enumerate() { rank[i] = r; } - let opts = fit::FitOptions { - polygon_tol: params.polygon_tolerance(), - fit_tol: params.fit_tolerance(), - corner_angle: params.corner_angle(), - snap_lines: params.snap_curves_to_lines, + let layers: Vec<(usize, usize)> = + order.iter().copied().enumerate().filter(|&(_, ci)| !(params.ignore_white && is_white(q.palette[ci]))).collect(); + let job = Layers { + q: &q, + rank: &rank, + params, + opts: fit::FitOptions { + polygon_tol: params.polygon_tolerance(), + fit_tol: params.fit_tolerance(), + corner_angle: params.corner_angle(), + snap_lines: params.snap_curves_to_lines, + }, + w, + h, + anchors: AtomicUsize::new(0), + max_anchors, }; - let min_hole = params.noise.max(1) as i64; let mut out = TraceResult::default(); - let mut mask = vec![false; w * h]; - for (r, &ci) in order.iter().enumerate() { + for (paths, &(_, ci)) in parallel_map(&layers, |&(r, ci)| job.layer(r, ci)).into_iter().zip(&layers) { + out.paths.extend(paths.ok_or(TraceError::TooComplex { max: max_anchors })?); let color = q.palette[ci]; - if params.ignore_white && is_white(color) { - continue; - } - let overlapping = params.method == Method::Overlapping; - for (m, &l) in mask.iter_mut().zip(&q.labels) { - *m = l != TRANSPARENT && if overlapping { rank[l as usize] >= r } else { l as usize == ci }; - } - for comp in trace_mask(&mask, w, h) { - let mut subs = Vec::with_capacity(1 + comp.holes.len()); - if let Some(sp) = fit::fit_loop(&comp.outer, &opts) { - subs.push(sp); - } else { - continue; - } - for hole in &comp.holes { - if hole.area2.abs() / 2 < min_hole && params.noise > 0 { - continue; - } - if let Some(sp) = fit::fit_loop(hole, &opts) { - subs.push(sp); - } - } - out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: comp.pixels }); - } if !out.palette.contains(&color) { out.palette.push(color); } } - out + Ok(out) +} + +/// What tracing one colour layer needs. +struct Layers<'a> { + q: &'a Quantized, + /// Each palette entry's layer, bottom (0) up. + rank: &'a [usize], + params: &'a TraceParams, + opts: fit::FitOptions, + w: usize, + h: usize, + /// Anchor points made so far, by every layer. + anchors: AtomicUsize, + max_anchors: usize, +} + +impl Layers<'_> { + /// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the + /// maximum. + fn layer(&self, r: usize, ci: usize) -> Option> { + let (params, opts) = (self.params, &self.opts); + let color = *self.q.palette.get(ci)?; + let overlapping = params.method == Method::Overlapping; + let mask: Vec = self + .q + .labels + .iter() + .map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci }) + .collect(); + let min_hole = params.noise.max(1) as i64; + let mut paths = vec![]; + for comp in trace_mask(&mask, self.w, self.h) { + let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue }; + let mut subs = vec![outer]; + for hole in &comp.holes { + if hole.area2.abs() / 2 < min_hole && params.noise > 0 { + continue; + } + subs.extend(fit::fit_loop(hole, opts)); + } + let path = PathData::new(subs); + let n = path.anchor_count(); + if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { + return None; + } + paths.push(TracedPath { path, color, pixels: comp.pixels }); + } + Some(paths) + } +} + +/// `f` of each of `items`, in order, spread over the available cores. +#[cfg(not(target_arch = "wasm32"))] +fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(items.len()); + if threads <= 1 { + return items.iter().map(f).collect(); + } + let next = AtomicUsize::new(0); + let mut done: Vec<(usize, R)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..threads) + .map(|_| { + s.spawn(|| { + let mut mine = vec![]; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + mine.push((i, f(item))); + } + mine + }) + }) + .collect(); + // A worker's panic goes on in this thread, as if it had run here (the engine's guard + // reports it). + workers.into_iter().flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e))).collect() + }); + done.sort_by_key(|(i, _)| *i); + done.into_iter().map(|(_, r)| r).collect() +} + +/// `f` of each of `items`, in order (no threads on the web). +#[cfg(target_arch = "wasm32")] +fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { + items.iter().map(f).collect() } #[cfg(test)] diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 5ce4a91a..7d51ad68 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -278,3 +278,73 @@ fn timing_1000x1000_bw_under_300ms() { assert!(!res.paths.is_empty()); assert!(ms < 300.0, "{ms} ms"); } + +/// Stripes of `n` colours, each with a few dots of the next one: many layers, many small shapes. +fn stripes(size: u32, n: u32) -> Raster { + let colour = |i: u32| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 53 % 256) as u8, 255]; + Raster::from_fn(size, size, |x, y| { + let band = x * n / size; + if (x % 9 < 3) && (y % 9 < 3) { colour(band + 1) } else { colour(band) } + }) +} + +/// #525: past the anchor budget the trace gives up with advice instead of making millions of +/// anchors; under it, it is the plain trace. +#[test] +fn a_trace_past_its_anchor_budget_is_refused() { + let img = stripes(120, 8); + let p = TraceParams { mode: Mode::Color, colors: 16, noise: 1, ..TraceParams::default() }; + let full = trace(&img, &p); + let anchors = full.anchor_count(); + assert!(anchors > 100, "{anchors} anchors"); + assert!(matches!(trace_within(&img, &p, anchors / 2), Err(TraceError::TooComplex { max }) if max == anchors / 2)); + assert_eq!(trace_within(&img, &p, anchors).unwrap(), full); +} + +/// Layers traced in parallel come out in the same order every time: bottom (largest) first. +#[test] +fn parallel_layers_keep_their_order() { + let img = stripes(90, 12); + let p = TraceParams { mode: Mode::Color, colors: 24, noise: 1, method: Method::Overlapping, ..TraceParams::default() }; + let a = trace(&img, &p); + assert!(a.palette.len() > 8, "{} colours", a.palette.len()); + for _ in 0..4 { + assert_eq!(trace(&img, &p), a); + } + // Overlapping: each layer's shapes cover the layers above, so the pixel counts fall upwards. + let firsts: Vec = a.palette.iter().filter_map(|c| a.paths.iter().find(|t| t.color == *c).map(|t| t.pixels)).collect(); + assert!(firsts.windows(2).all(|w| w[0] >= w[1]), "{firsts:?}"); +} + +/// A PNG whose header says `w` × `h` pixels, with no image data. +fn png_header(w: u32, h: u32) -> Vec { + fn crc(bytes: &[u8]) -> u32 { + let mut c = !0u32; + for &b in bytes { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut ihdr = b"IHDR".to_vec(); + ihdr.extend(w.to_be_bytes()); + ihdr.extend(h.to_be_bytes()); + ihdr.extend([8, 6, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + for chunk in [ihdr, b"IDAT".to_vec(), b"IEND".to_vec()] { + png.extend((chunk.len() as u32 - 4).to_be_bytes()); + png.extend(&chunk); + png.extend(crc(&chunk).to_be_bytes()); + } + png +} + +/// An image too large to trace is refused from its header, before it is decoded. +#[test] +fn an_image_too_large_to_trace_is_refused_before_decoding() { + let err = Raster::decode(&png_header(30_000, 30_000)).unwrap_err(); + assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}"); + assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3d06b944..e5d6047a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1041,6 +1041,27 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The most anchor points the selection's outlines and anchors, and the hover highlight, are drawn +/// for: an Image Trace of a photo selects hundreds of thousands of paths at once, whose outlines +/// would make a mesh larger than the GPU takes in one buffer (#525). +const OVERLAY_MAX_ANCHORS: usize = 100_000; + +/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] anchor points to outline (counting +/// stops there). +fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { + fn over(n: &Node, left: &mut usize) -> bool { + if let NodeKind::Path { path, .. } = &n.kind { + match left.checked_sub(path.anchor_count()) { + Some(l) => *left = l, + None => return true, + } + } + !matches!(n.kind, NodeKind::Envelope { .. }) && n.children().into_iter().flatten().any(|c| over(c, left)) + } + let mut left = OVERLAY_MAX_ANCHORS; + nodes.into_iter().any(|n| over(n, &mut left)) +} + /// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the /// envelope shows its mesh instead). fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { @@ -1160,7 +1181,7 @@ fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { if st.selection.contains(id) { return; } - if let Some(n) = st.doc.node(id) { + if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) { let color = c32(st.doc.layer_color(id)); stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color)); } @@ -1351,6 +1372,9 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { && app.ui.view.bounding_box && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); let bbox = if show_box { app.selection_box() } else { None }; + let st = app.session.active(); + let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); + let big_bounds = if big { app.selection_bounds() } else { None }; let app = &*app; let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); @@ -1363,7 +1387,11 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { // The selected anchors' handles (drawn once they are counted: Show handles when multiple // anchors are selected off shows them for a single one only). let (mut handles, mut with_handles) = (vec![], 0); - for id in &st.selection.objects { + // Too many paths to outline: the selection's bounds stand for them. + if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { + stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); + } + for id in st.selection.objects.iter().filter(|_| !big) { let Some(n) = st.doc.node(*id) else { continue }; let color = c32(st.doc.layer_color(*id)); let partial = st.selection.partial(*id); @@ -1633,11 +1661,13 @@ pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option { /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - if st.selection.is_empty() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") { + if app.session.active().is_none_or(|st| st.selection.is_empty()) + || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + { return; } - let Some(b) = st.doc.bounds_of(&st.selection.objects, true) else { return }; + let Some(b) = app.selection_bounds() else { return }; + let Some(st) = app.session.active() else { return }; let n = st.selection.len(); let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned(); let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. })); @@ -2024,6 +2054,29 @@ mod tests { assert_eq!(cmd_drag(&mut app, inside, inside), "selection"); } + /// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds, + /// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one + /// still shows its outline and anchors. + #[test] + fn a_huge_selection_shows_its_bounds_instead_of_every_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::(); + let ctx = egui::Context::default(); + let mut drawn = |n: usize| { + app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap(); + frame(&mut app, &ctx, vec![]); + let shapes = shapes(&mut app, &ctx); + let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum(); + let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count(); + (points, squares) + }; + let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10); + assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares"); + let (points, squares) = drawn(200); + assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares"); + } + /// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { let (shapes, mut delta) = frame_output(app, ctx); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 11affb70..4a0dd633 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3416,6 +3416,10 @@ axis Both Ambos @msg select a stroke with a variable width selecciona un trazo con anchura variable @msg select a symbol instance selecciona una instancia de símbolo @msg select an Image Trace object selecciona un objeto de calco de imagen +@msg could not decode image: {0} no se pudo decodificar la imagen: {0} +@msg image is empty la imagen está vacía +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) la imagen mide {width} × {height} píxeles, más de lo que admite Calco de imagen ({max} megapíxeles) +@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 este calco crearía más de {max} puntos de ancla, más de los que el documento puede mostrar con fluidez: sube Ruido, baja Trazados o Colores, o elige un preajuste de menor fidelidad @msg select an envelope selecciona una envolvente @msg select an image selecciona una imagen @msg select an object in perspective selecciona un objeto en perspectiva diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 83d19caf..4bb5c32c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3416,6 +3416,10 @@ axis Both Entrambi @msg select a stroke with a variable width seleziona una traccia con larghezza variabile @msg select a symbol instance seleziona un'istanza di simbolo @msg select an Image Trace object seleziona un oggetto di ricalco immagine +@msg could not decode image: {0} impossibile decodificare l'immagine: {0} +@msg image is empty l'immagine è vuota +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) l'immagine misura {width} × {height} pixel, più di quanto accetti Ricalco immagine ({max} megapixel) +@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 questo ricalco creerebbe più di {max} punti di ancoraggio, più di quanti il documento possa mostrare in modo fluido: aumenta Disturbo, riduci Tracciati o Colori, oppure scegli un preset a fedeltà inferiore @msg select an envelope seleziona un involucro @msg select an image seleziona un'immagine @msg select an object in perspective seleziona un oggetto in prospettiva diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 06c976b3..5253a0ea 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -711,7 +711,7 @@ fn complete_languages_translate_every_message() { const COMPLETE_MESSAGES: &[&str] = &["es", "it"]; /// 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"]; +const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"]; /// Where a message literal starts: a status, an error value, a `thiserror` message. const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b01bc125..eea9cbec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -249,6 +249,8 @@ pub struct CanvasCache { pub print_tiling: Option, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). pub selection_box: Option<((u64, u64, bool), Option)>, + /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). + pub selection_bounds: Option<((u64, u64), Option)>, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, } @@ -389,6 +391,7 @@ impl VectorcraftApp { slices: None, print_tiling: None, selection_box: None, + selection_bounds: None, cursors: Default::default(), }, perf: Perf::default(), @@ -704,6 +707,22 @@ impl VectorcraftApp { self.canvas.selection_box = Some((key, b)); b } + + /// The selection's visual bounds (stroke and effects included), square to the page: measured + /// once per revision, as the canvas reads it every frame (a traced photo selects a group of + /// hundreds of thousands of paths). + pub fn selection_bounds(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision); + if let Some((k, b)) = self.canvas.selection_bounds + && k == key + { + return b; + } + let b = st.doc.bounds_of(&st.selection.objects, true); + self.canvas.selection_bounds = Some((key, b)); + b + } } /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are From b832599a4798964520bae8732bb1486c8875e17b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 01:38:38 +0700 Subject: [PATCH 02/74] Fix #550: an SVG of chosen artboards holds only their art Exporting one artboard of many to SVG wrote the whole document's art into that artboard's file, the other artboards' art outside its viewBox: an editor opening it showed every artboard's art. A file for an artboard named in the export (Use Artboards with a range or All, Export for Screens) now holds the objects over that artboard only, at any depth of layers, as DXF's crop leaves them; objects reaching into it stay whole and a clipping layer keeps all of its objects. An SVG with no artboard named (Save As) still holds the whole document. Part of #546: Export As with a range writes the chosen artboard alone in SVG, DXF, PDF and PNG, each file with that artboard's art (tested from the dialogs). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/export.rs | 20 +++++++++++++++ crates/engine/src/cmd/fileio/svg.rs | 21 ++++++++++------ crates/engine/src/cmd/fileio/tests_svgenc.rs | 25 +++++++++++++++++++ crates/ui-egui/src/dialogs/svg_options.rs | 2 +- crates/ui-egui/src/dialogs/tests_export.rs | 26 ++++++++++++++++++++ docs/mcp.md | 2 +- 7 files changed, 87 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 553bd405..7784455f 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 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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), SVGs of chosen artboards holding only the art over each artboard (#550); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 543eda48..af2d441e 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -87,6 +87,26 @@ fn selected_part(n: &Node, keep: &HashSet) -> Option { Some(out) } +/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left +/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's +/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping +/// path among them. An artboard's file then holds its own art, not a whole document of artboards. +pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document { + fn kept(n: &Arc, rect: vectorcraft_geom::Rect) -> Option> { + if matches!(n.kind, NodeKind::Layer { clip: false, .. }) { + let mut layer = Node::clone(n); + let children = layer.children_mut()?; + *children = children.iter().filter_map(|c| kept(c, rect)).collect(); + return Some(Arc::new(layer)); + } + let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1; + n.visual_bounds().is_none_or(over).then(|| n.clone()) + } + let mut d = doc.clone(); + d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect(); + d +} + /// `p` without its artboard choice, also inside its SVG options (for documents made of one /// synthetic artboard, and for callers that pick the artboard themselves). pub(super) fn without_artboards(p: &Value) -> Value { diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 49a05878..263d5fd9 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -19,7 +19,7 @@ pub const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "true", - description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)", + description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each; a named artboard's file holds only the art over it)", }, FormatOption { name: "styling", @@ -125,8 +125,10 @@ pub fn options_map(p: &Value) -> Result, String> { Ok(m) } -/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers. -fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec>), String> { +/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers, +/// and whether `p` named those artboards (else the first stands for the whole document, as Save +/// writes it). +fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec>, bool), String> { let mut m = options_map(p)?; // Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines). if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance { @@ -138,21 +140,24 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec>) opts.check().map_err(|e| format!("SVG options: {e}"))?; let n = doc.artboards.len(); if n == 0 || boards.use_artboards == Some(false) { - return Ok((opts, vec![None])); + return Ok((opts, vec![None], false)); } - let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]); - Ok((opts, picked.into_iter().map(Some).collect())) + let named = boards.pick.resolve(n)?; + let chosen = named.is_some(); + Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen)) } /// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they /// link to and the writer's warnings. pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result { - let (opts, boards) = plan(doc, p)?; + let (opts, boards, chosen) = plan(doc, p)?; // Preserve editing embeds the native document (once, shared by every file). let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false)); let mut enc = Encoded::default(); for artboard in boards { - let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref()); + // A chosen artboard's file holds the art over it (#550). + let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect)); + let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref()); let bytes = out.take_bytes(); for l in out.linked { if !enc.linked.iter().any(|e| e.name == l.name) { diff --git a/crates/engine/src/cmd/fileio/tests_svgenc.rs b/crates/engine/src/cmd/fileio/tests_svgenc.rs index 5daf5479..e51f2b26 100644 --- a/crates/engine/src/cmd/fileio/tests_svgenc.rs +++ b/crates/engine/src/cmd/fileio/tests_svgenc.rs @@ -49,3 +49,28 @@ fn profile_and_embedded_fonts_reach_the_writer() { assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused"); assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused"); } + +/// #550: an SVG of chosen artboards holds the art over each, not every artboard's art; with no +/// artboard named (as Save writes it) the whole document's art is there. +#[test] +fn a_chosen_artboard_s_svg_holds_only_its_own_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap(); + let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0; + let (first, third) = (x(&s, 0), x(&s, 2)); + s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + // A rectangle has straight sides only; the ellipse is curves. + let shapes = |svg: &str| (svg.matches(" = all["files"].as_array().unwrap().iter().map(text).collect(); + assert_eq!(files.iter().map(|f| shapes(f)).collect::>(), [(1, false), (0, false), (1, true)]); + // Export for Screens names one artboard at a time the same way. + let screen = text(&s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap()); + assert_eq!(shapes(&screen), (1, false)); + let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap()); + assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art"); +} diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index daa9a9e0..fe9c9345 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -360,7 +360,7 @@ mod tests { let f = &mut app.ui.dialog.as_mut().unwrap().fields; f.insert("decimals".into(), json!(1)); f.insert("allArtboards".into(), json!(false)); - f.insert("range".into(), json!("2")); + f.insert("range".into(), json!("1")); super::super::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); let svg = text(&written, 0); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 7e67a439..90780ad3 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -240,3 +240,29 @@ fn pdf_artboards_continue_in_the_save_pdf_dialog() { let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); assert_eq!(pdf.artboards.len(), 2, "one page per artboard in the range"); } + +/// #546, #550: Export As with Use Artboards and a range writes the chosen artboard alone in every +/// format, each file holding that artboard's art (the ellipse is on artboard 1). +#[test] +fn export_as_writes_only_the_chosen_artboard_in_every_format() { + for (format, ellipse) in [("svg", "` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats` lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian after a byte order mark) or `latin1` (ISO 8859-1, other characters as `&#x…;` references); `document.serialize` still answers `text`, plus `dataBase64` From 1b43d8b8f932813ecce52ff8642b2f59efe04618 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 01:54:11 +0700 Subject: [PATCH 03/74] Fix #555: the font menu scrolls only its list egui's combo box puts its own scroll area around a combo's list, so the font menu, which scrolls its families itself, had two scrollbars: once the families reached their end the wheel scrolled the outer one, taking the search field and the filters out of view. The font menu (widgets::combo with searchable) now opens its list in a popup of its own, drawn like the combo box, without that outer scroll area: below the button, or above it when there is more room there. The families list fills the popup's room under the search field and filters, which stay at the top while the list scrolls. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/font_menu.rs | 10 ++--- crates/ui-egui/src/tests_font_menu.rs | 48 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 58 ++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 553bd405..3acc5d44 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 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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), the font menu scrolling only its list, under a search field and filters that stay put (#555); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4c6be54e..722f9fc6 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -307,12 +307,10 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp widgets::dim_label(ui, tl!("No matching fonts")); return None; } - // The rows: only those in view are laid out (there are thousands of families). The list fills - // what the popup shows below the filters (its clip: the popup doesn't grow past its own size, - // and a taller list would scroll inside a scrolling popup), down to the window's bottom. - const BOTTOM_GAP: f32 = 12.0; - let bottom = ui.clip_rect().bottom().min(ui.ctx().content_rect().bottom() - BOTTOM_GAP); - let room = (bottom - ui.next_widget_position().y).max(120.0); + // The rows: only those in view are laid out (there are thousands of families). The list is + // the menu's one scrolling part, under the search field and filters (#555): it fills the + // menu's room (its max rect) below them. + let room = (ui.max_rect().bottom() - ui.next_widget_position().y).clamp(120.0, 600.0); let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32)); // The highlight in view: centred as the menu opens, scrolled just enough as the keys move it. if let Some(top) = st.highlight.map(|i| i as f32 * row_h) { diff --git a/crates/ui-egui/src/tests_font_menu.rs b/crates/ui-egui/src/tests_font_menu.rs index a0d4a074..5b6c9fa8 100644 --- a/crates/ui-egui/src/tests_font_menu.rs +++ b/crates/ui-egui/src/tests_font_menu.rs @@ -270,3 +270,51 @@ fn menus_that_only_pick_a_font_leave_the_document_alone() { assert_eq!(picked(&mut app, Some(FontPick::Favorite("inter".into()))), None); assert!(app.ui.favorite_fonts.is_empty()); } + +/// #555: the menu's search field and filters stay at its top while the wheel scrolls the families, +/// past the list's end too: the list is the menu's one scrolling part. +#[test] +fn the_search_field_stays_put_while_the_list_scrolls() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |events: Vec| { + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 500.0))), ..Default::default() }; + let mut at = egui::Pos2::ZERO; + let mut out = ctx.run_ui(input, |ui| { + // Low in the window, as in a panel at the bottom of the dock: the menu opens upwards. + ui.add_space(390.0); + at = ui.next_widget_position(); + font_menu(ui, "test-scroll", "Inter", 260.0, None, MenuLook::default()); + }); + out.textures_delta.clear(); + let search = out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == "Search" => Some(t.pos), + _ => None, + }); + (at, search) + }; + let (at, _) = frame(vec![]); + let click = at + egui::vec2(40.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + let mut field = None; + for _ in 0..5 { + field = frame(vec![]).1; + } + let field = field.expect("the search field shows"); + let over_list = egui::pos2(field.x + 40.0, field.y + 120.0); + for dy in [-400.0, -4000.0, -4000.0] { + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }; + frame(vec![egui::Event::PointerMoved(over_list), wheel]); + for _ in 0..10 { + assert_eq!(frame(vec![]).1, Some(field), "the field stays where it was"); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a666281d..e6f27c73 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -655,11 +655,13 @@ pub(crate) fn combo( .stroke(Stroke::new(1.0, t.input_border)) .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { + if searchable { + return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list); + } egui::ComboBox::from_id_salt(ui.id().with(id)) .selected_text(egui::RichText::new(current).size(12.0)) .width(width - 4.0) - .close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick }) - .height(if searchable { f32::INFINITY } else { ui.spacing().combo_height }) + .height(ui.spacing().combo_height) .show_ui(ui, list) .inner .flatten() @@ -667,6 +669,58 @@ pub(crate) fn combo( .inner } +/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as +/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so +/// the field stays at the top and only the rows scroll (#555). A click outside closes it. +fn searchable_combo(ui: &mut Ui, id: egui::Id, current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option) -> Option { + let popup = id.with("popup"); + let open = egui::Popup::is_id_open(ui.ctx(), popup); + let pad = ui.spacing().button_padding; + let icon = Vec2::splat(ui.spacing().icon_width); + let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x, + egui::TextStyle::Button, + ); + let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); + let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + if ui.is_rect_visible(rect) { + let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; + ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); + let inner = rect.shrink2(pad); + ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color()); + // egui's combo arrow: a downward triangle. + let arrow = + Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45)); + ui.painter().add(egui::Shape::convex_polygon( + vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()], + v.fg_stroke.color, + Stroke::NONE, + )); + } + // It opens below the button, or above it when there is more room there, and the list gets + // the room left (the popup's frame aside). + const GAP: f32 = 12.0; + let screen = ui.ctx().content_rect(); + let frame = ui.spacing().menu_margin.sum().y + GAP; + let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame); + let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) }; + egui::Popup::menu(&resp) + .id(popup) + .width(rect.width()) + .align(align) + .align_alternatives(&[]) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(ui.available_width()); + ui.set_max_height(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + list(ui) + }) + .and_then(|r| r.inner) +} + /// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and /// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its /// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or From f31d6874aa45356164d5c6c5aad04a7247b57b56 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 02:08:56 +0700 Subject: [PATCH 04/74] Fix #541: a click on a selected object makes it the key object With the Selection tool, a click (no drag, no modifier) on one object of a selection of several makes it the key object (select.key); a click on the key again lets it go. The key object is drawn with a thicker outline, Align aligns to it (the Align panel shows Align to Key Object while there is a key, and choosing Selection or Artboard lets it go), and object.align without `to` aligns to the key when there is one, so the Control bar's and the Properties panel's Align buttons do too. With a spacing, Distribute Spacing keeps the key object still and spaces the others from it, and it reports too few objects instead of indexing an empty list. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 35 ++++++++++++------ crates/engine/src/cmd/select.rs | 10 +++++- crates/engine/src/tests.rs | 31 ++++++++++++++++ crates/tools/src/select.rs | 57 +++++++++++++++++++++++++----- crates/ui-egui/src/canvas.rs | 32 +++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/panels/align.rs | 37 +++++++++++++++++-- 9 files changed, 182 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4e8320e..2ec6cb36 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 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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), 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 91784cc4..0651a85f 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -159,7 +159,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_selection, align ), @@ -177,7 +177,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_multi, distribute_spacing ), @@ -813,9 +813,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() } +/// What `object.align` aligns to: `to`, else the key object when there is one, else the +/// selection. +fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { + str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key")) +} + fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { let st = s.doc()?; - match str_param(p, "to") { + match align_to(s, p) { Some("artboard") => { let b = st.doc.bounds_of(ids, preview).unwrap_or_default(); let i = st.doc.artboard_at(b.center()).unwrap_or(0); @@ -840,7 +846,7 @@ fn align(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; items_bounds(d, &ids, preview) .into_iter() - .filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key")) + .filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key")) .map(|(id, b)| { let dx = match h { Some("left") => r.x0 - b.x0, @@ -926,22 +932,31 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let mut items = items_bounds(&s.doc()?.doc, &ids, preview); items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let n = items.len(); + let &[(_, first), .., (_, last)] = items.as_slice() else { + return Err(EngineError::Other("select two or more objects to distribute".into())); + }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - let gap = match p.get("spacing").and_then(Value::as_f64) { + let spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite()); + let gap = match spacing { Some(g) => g, None => { - let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 }; + let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 }; (span - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64 } }; - let mut pos = start(&items[0].1); - let mut moves = vec![]; + let mut pos = start(&first); + let mut deltas = vec![]; for (id, r) in &items { - let delta = pos - start(r); - moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })); + deltas.push((*id, pos - start(r))); pos += size(r) + gap; } + // With a spacing (aligning to a key object) the key object stays where it is: the others + // are spaced from it. + let key = s.doc()?.selection.key; + let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); + let moves: Vec<(NodeId, Vec2)> = + deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); s.edit("Distribute Spacing", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 1d7a43d5..27d28684 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -36,7 +36,15 @@ pub fn specs() -> Vec { has_doc, toggle ), - cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key), + cmd!( + "select.key", + "Set Key Object", + [], + None, + "{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it", + has_doc, + key + ), cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), cmd!( "select.anchorsMany", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c4480abf..c97b572b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -198,6 +198,37 @@ fn align_left() { assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 0.0); } +/// #541: with the Selection tool, a click on one object of the selection makes it the key object: +/// aligning to the key moves the others to it, Distribute Spacing spaces them from it, and a click +/// on the key again lets it go. +#[test] +fn a_click_sets_the_key_object_that_align_and_spacing_keep_still() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0); + let c = rect(&mut s, 300.0, 60.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let click = |s: &mut Session| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 110.0, 40.0), v).unwrap(); + } + }; + click(&mut s); + assert_eq!(s.doc().unwrap().selection.key, Some(b)); + assert_eq!(s.doc().unwrap().selection.objects.len(), 3, "the selection stays"); + let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + // With a key object, aligning aligns to it (the Control bar and Properties name no target). + s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (100.0, 100.0, 100.0), "the others moved to the key"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 5, "bounds": "geometric"})).unwrap(); + assert_eq!((x0(&s, a), x0(&s, b), x0(&s, c)), (85.0, 100.0, 125.0), "5 pt apart, the key still"); + click(&mut s); + assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key again lets it go"); +} + #[test] fn selection_tool_drag_is_one_undo_step() { let mut s = session(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 32f035b4..675a97d3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -36,6 +36,9 @@ enum State { /// Shift-pressed on this selected object: released without a drag, it leaves the /// selection. deselect: Option, + /// Pressed (no modifier) on this object of a selection of several: released without a + /// drag, it becomes the key object that Align aligns to (the key again: no key). + key: Option, }, Scaling { handle: Handle, @@ -235,14 +238,14 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, began: false, deselect: None }; + self.state = State::Moving { start: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } match hit_test(cx.doc, p, cx.hit_options()) { Some(h) => { let top = h.top_object(cx.isolation); let mut out = vec![]; - let mut deselect = None; + let (mut deselect, mut key) = (None, None); if m.shift { // A Shift-click takes a selected object out of the selection when it // is released; a Shift-drag moves the selection, constrained. @@ -253,8 +256,10 @@ impl Tool for SelectionTool { } } else if !cx.selection.contains(top) { out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] }))); + } else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt { + key = Some(top); } - self.state = State::Moving { start: p, began: false, deselect }; + self.state = State::Moving { start: p, began: false, deselect, key }; out } None => { @@ -276,7 +281,7 @@ impl Tool for SelectionTool { if let Some(snap) = &self.moving { (d, self.guides) = snap.snap(cx, start, d); } - self.state = State::Moving { start, began: true, deselect: None }; + self.state = State::Moving { start, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -323,15 +328,18 @@ impl Tool for SelectionTool { self.state = State::Idle; b.finish() } - (PointerKind::Up, State::Moving { began, deselect, .. }) => { + (PointerKind::Up, State::Moving { began, deselect, key, .. }) => { self.state = State::Idle; self.measure = None; self.guides.clear(); self.moving = None; - match (began, deselect) { - (true, _) => vec![Action::Commit], - (false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (false, None) => vec![], + match (began, deselect, key) { + (true, ..) => vec![Action::Commit], + (false, Some(id), _) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + // A click on the key object again: no key. + (false, None, Some(id)) if cx.selection.key == Some(id) => vec![Action::Exec("select.key".into(), json!({}))], + (false, None, Some(id)) => vec![Action::Exec("select.key".into(), json!({ "id": id.0 }))], + (false, None, None) => vec![], } } (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { @@ -540,6 +548,37 @@ mod tests { assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); } + /// #541: a click on one object of a selection of several makes it the key object; a click on + /// the key again lets it go. A drag moves them all, a lone object has no key. + #[test] + fn a_click_on_a_selected_object_makes_it_the_key() { + let (d, id) = doc_with_area_type(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let p = paint(); + let click = |s: &Selection, x, y| { + let c = cx(&d, s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, x, y)) + }; + let mut s = Selection::default(); + s.add(rect); + s.add(id); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); + s.key = Some(rect); + assert_eq!(click(&s, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({}))], "the key again: no key"); + // A drag moves the selection and leaves the key alone. + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0)); + t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]); + // One object selected: a click on it does nothing. + let mut one = Selection::default(); + one.add(rect); + assert!(click(&one, 150.0, 150.0).is_empty()); + } + #[test] fn marquee_selects() { let (d, id) = doc_with_rect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5d6047a..4937c4c9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1041,6 +1041,9 @@ fn node_outline(n: &Node) -> BezPath { bp } +/// The width of the key object's outline (Align to Key Object), thicker than the selection's. +const KEY_OUTLINE: f32 = 2.5; + /// The most anchor points the selection's outlines and anchors, and the hover highlight, are drawn /// for: an Image Trace of a photo selects hundreds of thousands of paths at once, whose outlines /// would make a mesh larger than the GPU takes in one buffer (#525). @@ -1391,6 +1394,18 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) { stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id)))); } + // The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has + // too many paths to outline. + if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) { + let outline = if too_many_anchors([n]) { + n.geometric_bounds().map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()) + } else { + Some(node_outline(n)) + }; + if let Some(bp) = outline { + stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); + } + } for id in st.selection.objects.iter().filter(|_| !big) { let Some(n) = st.doc.node(*id) else { continue }; let color = c32(st.doc.layer_color(*id)); @@ -2077,6 +2092,23 @@ mod tests { assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares"); } + /// #541: the key object's outline is drawn thicker than the rest of the selection's. + #[test] + fn the_key_object_has_a_thicker_outline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let thick = + |app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count(); + assert_eq!(thick(&mut app), 0, "no key yet"); + app.session.execute("select.key", &json!({"id": b})).unwrap(); + assert!(thick(&mut app) > 0, "the key's outline"); + } + /// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { let (shapes, mut delta) = frame_output(app, ctx); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7c758ac0..a2fc2160 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3117,6 +3117,7 @@ axis Both Ambos @msg Release Guides: no guides Soltar guías: no hay guías @msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo @msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado +@msg select two or more objects to distribute selecciona dos o más objetos para distribuir @msg select paths or anchor points selecciona trazados o puntos de ancla @msg SVG parse error: {0} error al analizar el SVG: {0} @msg Save for Web: the image is empty Guardar para web: la imagen está vacía diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 72e17bee..8ea33549 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3117,6 +3117,7 @@ axis Both Entrambi @msg Release Guides: no guides Rilascia guide: nessuna guida @msg Release: no layer to put the rectangle in Rilascia: nessun livello in cui inserire il rettangolo @msg Replace Spine: select a blend and a path Sostituisci dorsale: seleziona una fusione e un tracciato +@msg select two or more objects to distribute seleziona due o più oggetti da distribuire @msg select paths or anchor points seleziona tracciati o punti di ancoraggio @msg SVG parse error: {0} Errore di analisi SVG: {0} @msg Save for Web: the image is empty Salva per il web: l'immagine è vuota diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index a469412f..5aa6619a 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -52,9 +52,20 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [ ("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"), ]; +/// Whether the selection has a key object. +fn has_key(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.key.is_some()) +} + +/// What Align aligns to: the key object while there is one (a click on an object of the +/// selection makes it the key), else the Align To choice. +fn align_to(app: &VectorcraftApp, ctx: &egui::Context) -> AlignTo { + if has_key(app) { AlignTo::Key } else { pstate(ctx, "align-to") } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n = selection_len(app); - let to: AlignTo = pstate(ui.ctx(), "align-to"); + let to = align_to(app, ui.ctx()); widgets::subheader(ui, tl!("Align Objects:")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; @@ -116,6 +127,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ] { if widgets::icon_button(ui, icon, tl!(tip), to == v, 28.0).clicked() { set_pstate(ui.ctx(), "align-to", v); + // Aligning to the selection or the artboard lets go of the key object. + if v != AlignTo::Key && has_key(app) { + app.run("select.key", json!({})).ok(); + } } } }); @@ -132,8 +147,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use Preview Bounds"), true, pb) { super::transform::set_pref(app, "usePreviewBounds", !pb); } - let to: AlignTo = pstate(ui.ctx(), "align-to"); - if menu_item(ui, tl!("Cancel Key Object"), to == AlignTo::Key, false) { + if menu_item(ui, tl!("Cancel Key Object"), align_to(app, ui.ctx()) == AlignTo::Key, false) { set_pstate(ui.ctx(), "align-to", AlignTo::Selection); app.run("select.key", json!({})).ok(); } @@ -152,4 +166,21 @@ mod tests { assert_eq!(p["to"], "selection"); assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); } + + /// #541: while the selection has a key object Align aligns to it, whatever Align To says; + /// choosing Align to Selection lets the key go. + #[test] + fn a_key_object_makes_align_align_to_it() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + assert_eq!(align_to(&app, &ctx), AlignTo::Selection); + app.run("select.key", json!({"id": b})).unwrap(); + assert_eq!(align_to(&app, &ctx), AlignTo::Key); + app.run("select.key", json!({})).unwrap(); + assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again"); + } } From b1f7d5ad7d8a8b4e726a2d4e70b2d251ee4b54e3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 02:20:21 +0700 Subject: [PATCH 05/74] Fix #563: the font menu's live preview no longer flickers Hovering a family in a font menu previews it on the selected text. The menu took its caller's font as the current family, and the caller reads it from the text, which the preview had just changed: the next frame the hovered family looked like the current one, so the menu ended the preview, and the frame after it previewed the family again. The text switched fonts every frame (and the document's revision ran up with it). The menu now keeps the family the text had as it opened as the current one: a preview stays on the text until another row is hovered, the current family is hovered again, or the menu closes. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/font_menu.rs | 10 ++++- crates/ui-egui/src/tests_font_menu.rs | 58 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4e8320e..6d087ab1 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 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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 menus' live preview staying on the text instead of flickering on and off every frame (#563); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4c6be54e..1544cec7 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -134,6 +134,10 @@ struct MenuState { /// The pass the list was last drawn in, and the pass it opened in. last: u64, opened: u64, + /// The family the text had as the menu opened. A preview shows another one in the document + /// (and the menu's caller), but this one stays the current family: hovering it ends the + /// preview (#563). + current: String, /// The highlighted row and the query and filters it belongs to. highlight: Option, query: String, @@ -194,6 +198,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp st.opened = pass; st.highlight = None; st.query.clear(); + st.current = current.to_string(); } st.last = pass; // For [`end_stale_preview`]: the menu is open, in this layer. @@ -243,8 +248,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp }); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); - // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック). - let current = db.canonical(current, "").0; + // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), + // as it was before any preview. + let current = db.canonical(&st.current, "").0; let current = current.as_str(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); // The generation is read first: fonts that load meanwhile make the next frame list them. diff --git a/crates/ui-egui/src/tests_font_menu.rs b/crates/ui-egui/src/tests_font_menu.rs index a0d4a074..ce6877f0 100644 --- a/crates/ui-egui/src/tests_font_menu.rs +++ b/crates/ui-egui/src/tests_font_menu.rs @@ -270,3 +270,61 @@ fn menus_that_only_pick_a_font_leave_the_document_alone() { assert_eq!(picked(&mut app, Some(FontPick::Favorite("inter".into()))), None); assert!(app.ui.favorite_fonts.is_empty()); } + +/// #563: a preview stays on the text while the menu stays on its family. The menu's caller passes +/// the text's font, which the preview changed; the menu keeps the font from before the preview as +/// the current one, so it doesn't take the previewed family for it and end the preview, then +/// preview it again, every frame (the text flickered). +#[test] +fn a_preview_stays_put_while_the_menu_is_still() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let font = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.first_style().font_family, + _ => String::new(), + }; + let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision; + // A panel's frame: the menu shows the text's font, and what it picks is applied. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }; + let current = font(app); + let (mut at, mut picked) = (egui::Pos2::ZERO, None); + let mut out = ctx.run_ui(input, |ui| { + crate::font_menu::end_stale_preview(app, ui.ctx()); + at = ui.next_widget_position(); + picked = font_menu(ui, "test-steady", ¤t, 260.0, None, MenuLook::default()); + if let Some(p) = picked.clone() { + crate::font_menu::apply(app, ui.ctx(), p); + } + }); + out.textures_delta.clear(); + (at, picked) + }; + let (at, _) = frame(&mut app, vec![]); + let click = at + egui::vec2(40.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(click), button(true)]); + frame(&mut app, vec![button(false)]); + for _ in 0..5 { + frame(&mut app, vec![]); + } + let (_, picked) = frame(&mut app, key(egui::Key::ArrowDown)); + assert!(matches!(picked, Some(FontPick::Preview(..))), "{picked:?}"); + let previewed = font(&app); + assert_ne!(previewed, "Inter"); + let rev = revision(&app); + for _ in 0..6 { + let (_, picked) = frame(&mut app, vec![]); + assert_eq!(picked, None, "nothing more to apply"); + } + assert_eq!((font(&app), revision(&app)), (previewed, rev), "the preview stays, untouched"); + // Back on the text's own family, the preview ends. + let (_, picked) = frame(&mut app, key(egui::Key::ArrowUp)); + assert_eq!(picked, Some(FontPick::EndPreview)); + assert_eq!(font(&app), "Inter"); +} From 92817d6ca197f7c55a2462b820e218a15eaf7d67 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:48:55 -0700 Subject: [PATCH 06/74] Widgets: check_tip, a checkbox whose tooltip shows over its box and label --- crates/ui-egui/src/widgets.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e6f27c73..78f45b48 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -962,8 +962,21 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { check3(ui, label, Some(value), enabled) } +/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. +pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, label, Some(value), enabled); + resp.on_hover_text(tip); + clicked +} + /// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { + check_row(ui, label, value, enabled).0 +} + +/// [`check3`], also returning the row's response (what a tooltip goes on: a widget around it never +/// counts as hovered under the box). +fn check_row(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> (bool, Response) { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, label, enabled); let checked = value == Some(true); @@ -981,7 +994,7 @@ pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> b } Some(false) => {} } - enabled && resp.clicked() + (enabled && resp.clicked(), resp) } /// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. From 02072c310aae178a27d9ada29995c2986238faaf Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:49:28 -0700 Subject: [PATCH 07/74] Format v4: large data after the JSON --- crates/format/src/lib.rs | 104 ++++++++++++++++++++++++++----- crates/format/tests/native_v3.rs | 2 +- crates/format/tests/native_v4.rs | 78 +++++++++++++++++++++++ 3 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 crates/format/tests/native_v4.rs diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5407240c..c6395c5c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -23,7 +23,10 @@ //! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as //! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour //! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be -//! compressed. Every version loads, and [`save_with`] writes any of them for older apps (under the +//! compressed; v4 may keep large data (images, the PDF, profiles of at least +//! [`INLINE_MAX`] bytes) after the JSON instead of in it: the JSON is followed by [`BLOB_MAGIC`] and +//! the bytes, and such an entry says where they are (`"blob": [offset, length]`) instead of giving +//! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the //! format name `drawcraft`, which every version reads). Readers reject files whose `version` is //! newer than they support. #![forbid(unsafe_code)] @@ -42,8 +45,12 @@ use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] pub use atomic::{write_atomic, write_atomic_with}; -/// v3: colour profiles as a document field, optional compression and preview (v1 and v2 files still load). -pub const VERSION: u32 = 3; +/// v4: large data after the JSON (v1 to v3 files still load). +pub const VERSION: u32 = 4; +/// Data this large or larger goes after the JSON (v4), not into it as base64. +pub const INLINE_MAX: usize = 8 << 10; +/// What separates the JSON from the data after it (v4). +pub const BLOB_MAGIC: &[u8] = b"\n\0VCBLOBS\0"; /// The oldest version [`save_with`] writes. pub const MIN_VERSION: u32 = 1; /// The first version whose readers open compressed files. @@ -87,12 +94,64 @@ pub enum FormatError { #[derive(Serialize, Deserialize)] struct Image { mime: String, + /// The bytes as base64, unless they come after the JSON (`blob`). + #[serde(default, skip_serializing_if = "String::is_empty")] data: String, + /// Where the bytes are in the data after the JSON (v4): `[offset, length]`. + #[serde(default, skip_serializing_if = "Option::is_none")] + blob: Option<[u64; 2]>, /// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes. #[serde(default, skip_serializing_if = "std::ops::Not::not")] proxy: bool, } +impl Image { + /// Its bytes: the base64 `data`, or its part of `tail` (the data after the JSON). + fn bytes(&self, tail: &[u8]) -> Option> { + match self.blob { + Some([offset, len]) => { + let start = usize::try_from(offset).ok()?; + let end = start.checked_add(usize::try_from(len).ok()?)?; + tail.get(start..end).map(<[u8]>::to_vec) + } + None => base64_decode(&self.data), + } + } +} + +/// Entries written for one file: large bytes go to the data after the JSON when the version keeps +/// them there. +struct Blobs { + tail: Vec, + outside: bool, +} + +impl Blobs { + fn image(&mut self, mime: &str, bytes: &[u8], proxy: bool) -> Image { + if self.outside && bytes.len() >= INLINE_MAX { + let at = self.tail.len() as u64; + self.tail.extend_from_slice(bytes); + return Image { mime: mime.into(), data: String::new(), blob: Some([at, bytes.len() as u64]), proxy }; + } + Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy } + } +} + +/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON +/// value. +fn split_tail(text: &[u8]) -> (&[u8], &[u8]) { + let mut values = serde_json::Deserializer::from_slice(text).into_iter::(); + if values.next().is_some_and(|v| v.is_ok()) { + let end = values.byte_offset(); + if let Some(rest) = text.get(end..) + && rest.starts_with(BLOB_MAGIC) + { + return (text.get(..end).unwrap_or(text), rest.get(BLOB_MAGIC.len()..).unwrap_or_default()); + } + } + (text, &[]) +} + #[derive(Deserialize)] struct File { format: String, @@ -189,6 +248,7 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError // Images are only referenced by image objects' `key` (wherever the objects are: layers, // symbols, patterns, masks, or foreign data). let used = if blobs.is_empty() { HashSet::new() } else { string_members(&body, "key") }; + let mut out_blobs = Blobs { tail: vec![], outside: o.version >= 4 }; let images: BTreeMap<&str, Image> = blobs .iter() .filter(|(k, _)| used.contains(k.as_str())) @@ -196,20 +256,20 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError // Include Linked Files keeps the file's bytes whenever they are loaded. let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); let image = match b.proxy.as_ref().filter(|_| preview_only) { - Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true }, - None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false }, + Some(p) => out_blobs.image(vectorcraft_doc::links::PROXY_MIME, p, true), + None => out_blobs.image(&b.mime, &b.bytes, false), }; (k.as_str(), image) }) .collect(); - let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), proxy: false }; - let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), blob(ICC_MIME, icc))).collect(); - let pdf = o.pdf.as_deref().map(|p| blob("application/pdf", p)); + let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), out_blobs.image(ICC_MIME, icc, false))).collect(); + let pdf = o.pdf.as_deref().map(|p| out_blobs.image("application/pdf", p, false)); + let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy: false }; let carried = profiles.values().chain(&pdf).map(|i| i.data.len()).sum::(); let size = body.len() + images.values().map(|i| i.data.len()).sum::() + carried + 256; let mut w = Envelope { out: Vec::with_capacity(size), pretty }; // Older apps only know the name from before the rename. - w.field("format", &if o.version < VERSION { LEGACY_EXTENSION } else { EXTENSION })?; + w.field("format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?; w.field("version", &o.version)?; w.field("generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; if let Some(png) = &o.preview { @@ -223,7 +283,11 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError } w.raw("document", &body); w.field("images", &images)?; - let out = w.finish(); + let mut out = w.finish(); + if !out_blobs.tail.is_empty() { + out.extend_from_slice(BLOB_MAGIC); + out.extend_from_slice(&out_blobs.tail); + } if !o.compress { return Ok(out); } @@ -356,7 +420,8 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> { /// [`load`], with everything else the file carries. pub fn load_file(bytes: &[u8]) -> Result { let text = unpack(bytes)?; - let f: File = serde_json::from_slice(&text).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; + let (json, tail) = split_tail(&text); + let f: File = serde_json::from_slice(json).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; if f.format != EXTENSION && f.format != LEGACY_EXTENSION { return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format))); } @@ -365,7 +430,7 @@ pub fn load_file(bytes: &[u8]) -> Result { } let mut doc = f.document; for (k, img) in f.images { - let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?; + let bytes = img.bytes(tail).ok_or_else(|| FormatError::BadImage(k.clone()))?; let mut blob = ImageBlob::new(img.mime, bytes); // Until the linked file is read, the preview stands in for it. if img.proxy { @@ -382,7 +447,7 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.fix_next_id(); // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); - let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect(); + let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, icc.bytes(tail)?))).collect(); Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } @@ -393,19 +458,24 @@ struct Head { pdf: Option, } -fn head(bytes: &[u8]) -> Option { - serde_json::from_slice(&unpack(bytes).ok()?).ok() +/// The file's head and the data after its JSON. +fn head(bytes: &[u8]) -> Option<(Head, Vec)> { + let text = unpack(bytes).ok()?; + let (json, tail) = split_tail(&text); + Some((serde_json::from_slice(json).ok()?, tail.to_vec())) } /// The preview PNG embedded in a native file (`None`: it has none, or isn't a native file). pub fn preview(bytes: &[u8]) -> Option> { - base64_decode(&head(bytes)?.preview?.data) + let (h, tail) = head(bytes)?; + h.preview?.bytes(&tail) } /// The PDF a native file saved with Create PDF-Compatible File carries (`None`: it has none, or /// isn't a native file). pub fn pdf_content(bytes: &[u8]) -> Option> { - base64_decode(&head(bytes)?.pdf?.data) + let (h, tail) = head(bytes)?; + h.pdf?.bytes(&tail) } /// Does this look like a `.vectorcraft` file (compressed or not)? diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index cde12f0d..8ca0d21d 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -55,7 +55,7 @@ fn compressed_files_load() { assert!(!flate2_unpack(&packed).contains(&b'\n')); // A legacy (v2, pre-rename) file compressed by hand opens too. let text = String::from_utf8(plain.clone()).unwrap(); - let legacy = text.replacen("\"format\":\"vectorcraft\",\"version\":3", "\"format\":\"drawcraft\",\"version\":2", 1); + let legacy = text.replacen(&format!("\"format\":\"vectorcraft\",\"version\":{VERSION}"), "\"format\":\"drawcraft\",\"version\":2", 1); assert_ne!(legacy, text); let legacy = gzip(legacy.as_bytes()); assert!(sniff(&legacy)); diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/native_v4.rs new file mode 100644 index 00000000..34f6c6a8 --- /dev/null +++ b/crates/format/tests/native_v4.rs @@ -0,0 +1,78 @@ +//! `.vectorcraft` v4: large data after the JSON (images, the PDF, profiles), small files still plain +//! JSON, older versions inline, damaged offsets refused. +// 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::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_format::{BLOB_MAGIC, FormatError, INLINE_MAX, SaveOptions, load, load_file, pdf_content, save_with}; +use vectorcraft_geom::Affine; + +/// A document showing image `key` of `len` bytes. +fn doc_with_image(key: &str, len: usize) -> Document { + let mut d = Document::new(100.0, 100.0); + let bytes: Vec = (0..len).map(|i| (i * 7 % 251) as u8).collect(); + d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); + let l = d.layers[0].id; + let id = d.alloc_id(); + let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d +} + +fn has_tail(bytes: &[u8]) -> bool { + bytes.windows(BLOB_MAGIC.len()).any(|w| w == BLOB_MAGIC) +} + +#[test] +fn large_data_goes_after_the_json_and_reads_back() { + let d = doc_with_image("big", INLINE_MAX + 10); + let pdf = vec![b'%'; INLINE_MAX * 2]; + for compress in [false, true] { + let o = SaveOptions { compress, pdf: Some(pdf.clone()), ..SaveOptions::default() }; + let bytes = save_with(&d, &o).unwrap(); + if !compress { + assert!(has_tail(&bytes), "a binary section"); + // The JSON before it holds no base64 copy of the image. + assert!(bytes.len() < INLINE_MAX + 10 + pdf.len() + (32 << 10), "{} bytes", bytes.len()); + } + let back = load(&bytes).unwrap(); + assert_eq!(back.images["big"].bytes, d.images["big"].bytes, "compress {compress}"); + assert_eq!(pdf_content(&bytes).unwrap(), pdf); + } +} + +#[test] +fn small_files_stay_plain_json_and_older_versions_inline() { + let small = save_with(&doc_with_image("small", 100), &SaveOptions::default()).unwrap(); + assert!(!has_tail(&small)); + let v: Value = serde_json::from_slice(&small).unwrap(); + assert_eq!(v["version"], 4); + let big = doc_with_image("big", INLINE_MAX + 10); + let v3 = save_with(&big, &SaveOptions { version: 3, ..SaveOptions::default() }).unwrap(); + assert!(!has_tail(&v3), "version 3 keeps everything in the JSON"); + let v: Value = serde_json::from_slice(&v3).unwrap(); + assert_eq!((v["format"].as_str(), v["version"].as_u64()), (Some("vectorcraft"), Some(3))); + assert_eq!(load(&v3).unwrap().images["big"].bytes, big.images["big"].bytes); +} + +#[test] +fn damaged_offsets_are_refused_not_a_crash() { + let d = doc_with_image("big", INLINE_MAX + 10); + let bytes = save_with(&d, &SaveOptions::default()).unwrap(); + // The data cut short. + let cut = &bytes[..bytes.len() - 100]; + assert!(matches!(load_file(cut), Err(FormatError::BadImage(_)))); + // An offset past the end. + let text = String::from_utf8_lossy(&bytes); + let at = text.find("\"blob\":[").unwrap(); + let mut damaged = bytes.clone(); + damaged.splice(at..at + 8, b"\"blob\":[9".iter().copied()); + assert!(load_file(&damaged).is_err()); + // A missing section. + let json_only = &bytes[..bytes.windows(BLOB_MAGIC.len()).position(|w| w == BLOB_MAGIC).unwrap()]; + assert!(load_file(json_only).is_err()); + let _ = Arc::new(()); +} From 6d56195edaae3871ff208ea5f107c98e96c62781 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:52:12 -0700 Subject: [PATCH 08/74] Placed documents: the object, drawn exactly and kept as art by every exporter --- crates/cad/src/scene.rs | 2 +- crates/doc/src/blend.rs | 4 +- crates/doc/src/hit.rs | 9 +- crates/doc/src/lib.rs | 2 + crates/doc/src/links.rs | 4 +- crates/doc/src/live.rs | 5 +- crates/doc/src/node.rs | 10 + crates/doc/src/placed_document.rs | 684 +++++++++++++++++++ crates/effects/src/bake.rs | 17 +- crates/effects/src/reshape.rs | 1 + crates/engine/src/cmd/clipboard/resources.rs | 2 + crates/engine/src/cmd/distortcmds.rs | 1 + crates/engine/src/cmd/docinfo.rs | 4 +- crates/engine/src/cmd/flatten.rs | 2 + crates/eps/src/scene.rs | 2 +- crates/format/src/lib.rs | 17 +- crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export.rs | 2 +- crates/render/src/lib.rs | 24 +- crates/render/src/live.rs | 1 + crates/render/src/placed_document.rs | 88 +++ crates/render/src/tests_placed_document.rs | 83 +++ crates/svg/src/export.rs | 4 +- crates/testkit/src/invariants.rs | 13 +- 24 files changed, 957 insertions(+), 26 deletions(-) create mode 100644 crates/doc/src/placed_document.rs create mode 100644 crates/render/src/placed_document.rs create mode 100644 crates/render/src/tests_placed_document.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 96766913..21ae0278 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -315,7 +315,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index e41efc8a..5695834a 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -527,7 +527,8 @@ impl Lerp { (NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh, (NodeKind::Text(_), NodeKind::Text(_)) | (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. }) - | (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed, + | (NodeKind::Image(_), NodeKind::Image(_)) + | (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed, _ => match (node_path(a), node_path(b)) { (Some((pa, ra, ma)), Some((pb, rb, mb))) => { Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) } @@ -586,6 +587,7 @@ impl Lerp { *o = lerp_affine(*x, *y, t) } (NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t), + (NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t), _ => {} } n.mask = None; diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 9db6e3dc..a741c109 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -199,9 +199,12 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?; on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) } - NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { - n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) - } + NodeKind::Text(_) + | NodeKind::Image(_) + | NodeKind::PlacedDocument(_) + | NodeKind::SymbolInstance { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds), NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a9fb74da..e3b54a84 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -24,6 +24,7 @@ pub mod overprint; pub mod pattern; pub mod perspective; mod pixels; +pub mod placed_document; pub mod profiles; pub mod puppet; pub mod range; @@ -72,6 +73,7 @@ pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, N pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; +pub use placed_document::PlacedDocument; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 2cd24d5a..d32ab993 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -137,7 +137,7 @@ impl Document { } } - /// The keys of image blobs only linked images show: what a save writes as their preview. + /// The keys of image blobs only linked images (or placed documents, always linked) show: what a save writes as their preview. pub fn linked_only_images(&self) -> BTreeSet { if self.images.values().all(|b| b.proxy.is_none()) { return BTreeSet::new(); @@ -152,6 +152,8 @@ impl Document { u.1 = true; } }); + // A placed document saves its file's preview the same way. + self.visit_placed(|_, p| uses.entry(p.key.as_str()).or_default().0 = true); uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect() } } diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 339a1cc8..51641b14 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1447,6 +1447,8 @@ fn map_node(n: &Node, w: &Warper) -> Node { let im = im.clone(); return warp_image(&out, &im, w); } + // Its art is plain, so it warps at this depth. + NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), } out } @@ -1607,7 +1609,7 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine /// Is this one of the live kinds? pub fn is_live(n: &Node) -> bool { - matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_)) + matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_)) } /// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. @@ -1641,6 +1643,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), + NodeKind::PlacedDocument(p) => p.art(n), _ => vec![n.clone()], } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 845f6f2e..13ff5298 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -423,6 +423,9 @@ pub enum NodeKind { Mesh(GradientMesh), /// Live Repeat (radial / grid / mirror) of source art. Repeat(RepeatSpec), + /// A placed document: an artboard of another VectorCraft file, linked and locked (see + /// [`crate::placed_document`]). + PlacedDocument(Box), } fn yes() -> bool { @@ -642,6 +645,7 @@ impl Node { NodeKind::Envelope { .. } => "Envelope", NodeKind::Mesh(_) => "Mesh", NodeKind::Repeat(r) => r.kind.label(), + NodeKind::PlacedDocument(_) => "Placed Document", } } /// Name shown in the Layers panel: explicit name or ``. @@ -715,6 +719,7 @@ impl Node { NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), + NodeKind::PlacedDocument(p) => Some(p.bounds()), } } /// Visual bounds: what the object paints, its strokes included (paths and compound paths @@ -810,6 +815,7 @@ impl Node { } } NodeKind::Image(im) => im.xf = a * im.xf, + NodeKind::PlacedDocument(p) => p.xf = a * p.xf, NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf, NodeKind::Blend { children, spec } => { for c in children.iter_mut() { @@ -972,6 +978,10 @@ impl Node { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); out.push((frame.to_bezpath(), FillRule::NonZero)); } + NodeKind::PlacedDocument(p) => { + let frame = shapes::rectangle(p.natural()).transformed(p.xf); + out.push((frame.to_bezpath(), FillRule::NonZero)); + } NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { crate::live::expand_deep(self, text).push_clip_shapes(text, out); } diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs new file mode 100644 index 00000000..2e0e93da --- /dev/null +++ b/crates/doc/src/placed_document.rs @@ -0,0 +1,684 @@ +//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]). +//! +//! The object keeps the link to the file and the file's bytes, stored like image data in +//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the +//! document shows correctly without the file. The art it shows is read from those bytes and never +//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting +//! expand it like a live object ([`crate::live::expanded`]). +//! +//! The file is read by the native format's loader, which lives above this crate and registers +//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting, +//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses +//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy +//! of the document, so they never meet the user's or show in panels. Placed documents inside a +//! placed document are read the same way, up to [`MAX_DEPTH`] deep (documents that place each +//! other stop there, showing their previews). + +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; + +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::{Affine, Rect}; + +use vectorcraft_color::Paint; + +use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol}; + +/// A placed document: an artboard of another VectorCraft file, shown as one locked object. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacedDocument { + /// The file (its `page` is the artboard shown, 1-based). + pub link: LinkInfo, + /// Key into [`Document::images`]: the file's bytes (see [`key_for`]). + pub key: String, + /// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`). + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub bounding: bool, + /// The artboard's size (or the art's) at 100% (pt). + pub width: f64, + pub height: f64, + /// Maps the box (0..width, 0..height) into the document. + pub xf: Affine, + /// Links panel → Placement Options: how the file read again takes this object's place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub placement: PlacementOptions, +} + +/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing +/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and +/// its preview. +pub fn key_for(hash: &str, page: u32, bounding: bool) -> String { + format!("{hash}-{page}{}", if bounding { "b" } else { "" }) +} + +impl PlacedDocument { + /// The artboard shown (1-based). + pub fn page(&self) -> u32 { + self.link.page.unwrap_or(1) + } + + /// The box at 100%. + pub fn natural(&self) -> Rect { + Rect::new(0.0, 0.0, self.width, self.height) + } + + /// The four corners of the box in the document. + pub fn corners(&self) -> [vectorcraft_geom::Point; 4] { + let r = self.natural(); + [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.xf * vectorcraft_geom::Point::new(x, y)) + } + + /// The box in the document (axis-aligned). + pub fn bounds(&self) -> Rect { + self.xf.transform_rect_bbox(self.natural()) + } + + /// The transform from the file's coordinates (its artboard or art bounds `frame`) to the + /// document; `None` when either size is empty. + pub fn art_xf(&self, frame: Rect) -> Option { + let ok = self.width > 0.0 && self.height > 0.0 && frame.width() > 0.0 && frame.height() > 0.0; + ok.then(|| { + self.xf * Affine::scale_non_uniform(self.width / frame.width(), self.height / frame.height()) * Affine::translate((-frame.x0, -frame.y0)) + }) + } + + /// The art the object shows, as plain objects with `n`'s id: one group, its resources named + /// as [`Document::with_placed_art`] adds them (nothing until that has read it). + pub fn art(&self, n: &Node) -> Vec { + let prefix = prefix(&self.key); + read_cached(self).and_then(|e| self.art_node(n, &e, &prefix)).into_iter().collect() + } + + /// Read `e`'s art placed as this object `n` shows it (`n`'s id), its resources renamed with + /// `prefix`. + fn art_node(&self, n: &Node, e: &ReadDocument, prefix: &str) -> Option { + let m = self.art_xf(e.frame)?; + let mut g = (*e.art).clone(); + rename(&mut g, prefix); + g.transform(m, true); + each_mut(&mut g, &mut |c| crate::pattern::transform_pattern_paints(c, m)); + g.id = n.id; + Some(g) + } + + /// An image object showing preview `bytes` (stored under this object's key) in this object's + /// box: what it shows and outputs as when the file can't be read. + pub fn preview_image(&self, bytes: &[u8]) -> Option { + let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; + let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64); + Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() }) + } +} + +/// The names the resources of the art stored under `key` take in a prepared document. +fn prefix(key: &str) -> String { + format!("{RESOURCE_PREFIX}{key}/") +} + +/// `n` and every descendant, opacity-mask art included. +fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { + f(n); + if let Some(m) = &n.mask { + walk_all(&m.art, f); + } + for c in n.children().into_iter().flatten() { + walk_all(c, f); + } +} + +/// The roots of `d`'s art: layers, symbol definitions and pattern swatches. +fn roots(d: &Document) -> impl Iterator> { + d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| &p.art)) +} + +fn roots_mut(d: &mut Document) -> impl Iterator> { + d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| &mut p.art)) +} + +impl Document { + /// Visit every placed document: in the layers, then in symbol definitions and pattern + /// swatches, opacity-mask art included. + pub fn visit_placed<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a PlacedDocument)) { + for root in roots(self) { + walk_all(root, &mut |n| { + if let NodeKind::PlacedDocument(p) = &n.kind { + f(n, p); + } + }); + } + } + + /// Does the document hold a placed document anywhere? + pub fn has_placed(&self) -> bool { + let mut any = false; + self.visit_placed(|_, _| any = true); + any + } + + /// Every placed document (those in the art they show too) replaced by a plain group of that + /// art, for apps that don't know them: call it on [`Self::with_placed_art`]'s copy. Only the + /// subtrees holding one are copied. + pub fn placed_as_groups(&mut self) { + fn holds(n: &Node) -> bool { + let mut hit = false; + walk_all(n, &mut |c| hit |= matches!(c.kind, NodeKind::PlacedDocument(_))); + hit + } + fn update(n: &mut Arc) { + if !holds(n) { + return; + } + if matches!(n.kind, NodeKind::PlacedDocument(_)) { + let mut g = crate::live::expanded_group(n, None); + g.appearance = n.appearance.clone(); + *n = Arc::new(g); + } + // The art shown may hold placed documents too. + let n = Arc::make_mut(n); + if let Some(m) = &mut n.mask { + update(&mut m.art); + } + for c in n.children_mut().into_iter().flatten() { + update(c); + } + } + for root in roots_mut(self) { + update(root); + } + } + + /// Placed documents whose key is in `keys` replaced by images of their previews (for output + /// when their files can't be read). + pub fn placed_as_previews(&mut self, keys: &BTreeSet) { + fn update(n: &mut Arc, keys: &BTreeSet, images: &std::collections::BTreeMap) { + let mut hit = false; + walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::PlacedDocument(p) if keys.contains(&p.key))); + if !hit { + return; + } + let n = Arc::make_mut(n); + if let NodeKind::PlacedDocument(p) = &n.kind + && keys.contains(&p.key) + { + if let Some(im) = images.get(&p.key).and_then(|b| p.preview_image(&b.bytes)) { + n.kind = NodeKind::Image(im); + } + return; + } + if let Some(m) = &mut n.mask { + update(&mut m.art, keys, images); + } + for c in n.children_mut().into_iter().flatten() { + update(c, keys, images); + } + } + let images = self.images.clone(); + for root in roots_mut(self) { + update(root, keys, &images); + } + } +} + +// ---------- previews and the files' bytes ---------- + +/// A placed document's preview: this many pixels per point of its box… +pub const PREVIEW_PX_PER_PT: f64 = 1.0; +/// …and at most this many on its longer side. +pub const PREVIEW_MAX: u32 = 1024; +/// How deep placed documents inside placed documents are read; deeper ones show their previews. +pub const MAX_DEPTH: usize = 8; + +/// Reads `p`'s file again (`None`: not found, or changed since it was read). The engine registers +/// one with [`set_file_reader`]. +pub type FileReader = fn(&PlacedDocument) -> Option>; + +static FILE_READER: OnceLock = OnceLock::new(); + +/// Register how files are read again (the first registration stays). +pub fn set_file_reader(f: FileReader) { + // Already set: the same engine, registered by another session. + let _ = FILE_READER.set(f); +} + +/// The most bytes of files read again kept. +const FULL_BYTES: usize = 512 << 20; + +/// Files read again, by key, the most recently read last. +type FullFiles = Vec<(String, Arc>)>; + +static FULL: Mutex = Mutex::new(Vec::new()); + +fn full() -> MutexGuard<'static, FullFiles> { + // Entries are only pushed and removed: a panic while held leaves it whole. + FULL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The file's bytes when they are at hand: stored (`blob` isn't just the preview) or read again +/// already. Never reads it. +pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option>> { + if !blob.is_proxy() { + return Some(blob.bytes.clone()); + } + full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone()) +} + +/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None` +/// when only the preview can be had. +pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option>> { + if let Some(b) = full_bytes_cached(p, blob) { + return Some(b); + } + let b = Arc::new(FILE_READER.get()?(p)?); + let mut c = full(); + c.push((p.key.clone(), b.clone())); + let mut total: usize = c.iter().map(|(_, b)| b.len()).sum(); + while total > FULL_BYTES && c.len() > 1 { + total = total.saturating_sub(c.remove(0).1.len()); + } + Some(b) +} + +/// The size in pixels of image bytes `bytes` (a preview), read from its header. +pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { + image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok() +} + +// ---------- reading files ---------- + +/// The prefix of the names the resources of placed documents' art take in a prepared document: +/// `placed:/`. +pub const RESOURCE_PREFIX: &str = "placed:"; + +/// Reads a placed document's file (its bytes) into what it shows: a document holding the +/// resources (no layers), the art as one group in the file's coordinates, and the frame shown +/// (the artboard, or the art's bounds). The native format lives above this crate, so the engine +/// registers one with [`set_loader`]. +pub type Loader = fn(&PlacedDocument, &[u8]) -> Option<(Document, Node, Rect)>; + +static LOADER: OnceLock = OnceLock::new(); + +/// Register how files are read (the first registration stays). +pub fn set_loader(l: Loader) { + // Already set: the same loader, registered by another session. + let _ = LOADER.set(l); +} + +/// The most files kept read whatever their size. +const CACHE_SIZE: usize = 64; +/// Past [`CACHE_SIZE`], the most bytes of files kept read (their own sizes, standing for what +/// reading them holds). +const CACHE_BYTES: usize = 256 << 20; + +/// A placed document's file read: the art it shows, ready to draw with its own resources. +pub struct ReadDocument { + key: String, + /// Read from the preview, not the file. + preview: bool, + /// The bytes read (what tells a cached read is still the one shown). + bytes: Arc>, + /// The artboard (or art bounds) shown: what the object's box shows. + pub frame: Rect, + /// The art as one group, in the file's coordinates. Its images, symbols and patterns are + /// `doc`'s, under their own names. + pub art: Arc, + /// The file's resources and setup (no layers: the art is `art`). + pub doc: Arc, +} + +struct Entry { + e: Arc, + used: u64, +} + +#[derive(Default)] +struct Cache { + entries: std::collections::HashMap<(String, bool), Entry>, + clock: u64, +} + +static CACHE: std::sync::LazyLock> = std::sync::LazyLock::new(Default::default); + +fn cache() -> MutexGuard<'static, Cache> { + // A panic while the cache was held leaves it whole (entries are only inserted and removed). + CACHE.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Cache { + /// The read of `key` (its preview's when `preview`; made from `bytes` when given), marked used. + fn get(&mut self, key: &str, preview: bool, bytes: Option<&Arc>>) -> Option> { + self.clock += 1; + let clock = self.clock; + let e = self.entries.get_mut(&(key.to_string(), preview))?; + if bytes.is_some_and(|b| !Arc::ptr_eq(&e.e.bytes, b)) { + return None; + } + e.used = clock; + Some(e.e.clone()) + } + + fn insert(&mut self, e: Arc) { + self.clock += 1; + self.entries.insert((e.key.clone(), e.preview), Entry { e, used: self.clock }); + // The least recently used go while over both limits. + let mut total: usize = self.entries.values().map(|x| x.e.bytes.len()).sum(); + while self.entries.len() > CACHE_SIZE && total > CACHE_BYTES { + let Some(k) = self.entries.iter().min_by_key(|(_, x)| x.used).map(|(k, _)| k.clone()) else { break }; + if let Some(x) = self.entries.remove(&k) { + total = total.saturating_sub(x.e.bytes.len()); + } + } + } +} + +/// `p`'s file (`bytes`) read, from the cache when it was read already: what the renderer draws a +/// placed document from. `None` when it can't be read (or no loader is registered). +pub fn read(p: &PlacedDocument, bytes: &Arc>) -> Option> { + if let Some(e) = cache().get(&p.key, false, Some(bytes)) { + return Some(e); + } + let loader = LOADER.get()?; + let (doc, art, frame) = loader(p, bytes)?; + let e = Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) }); + cache().insert(e.clone()); + Some(e) +} + +/// `p`'s preview (`bytes`) read as art: one image filling the box. +fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option> { + if let Some(e) = cache().get(&p.key, true, Some(bytes)) { + return Some(e); + } + let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; + let frame = p.natural(); + let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0)); + doc.layers.clear(); + doc.swatches.clear(); + let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" }; + doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None }); + let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64); + let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() }; + let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(im)))], clip: false }); + let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) }); + cache().insert(e.clone()); + Some(e) +} + +/// `p`'s file as read already (else its preview's), without reading it. +pub fn read_cached(p: &PlacedDocument) -> Option> { + let mut c = cache(); + c.get(&p.key, false, None).or_else(|| c.get(&p.key, true, None)) +} + +/// Keep `read` (what [`Loader`] gives), just read from `bytes` for `p` (when it was placed), so +/// drawing it doesn't read it again. +pub fn prime(p: &PlacedDocument, bytes: Arc>, (doc, art, frame): (Document, Node, Rect)) { + cache().insert(Arc::new(ReadDocument { key: p.key.clone(), preview: false, bytes, frame, art: Arc::new(art), doc: Arc::new(doc) })); +} + +/// The id the read art's own nodes take (their object's, once expanded). +fn art_id() -> crate::NodeId { + crate::NodeId(0) +} + +/// Visit `n` and every descendant (opacity-mask art included) mutably. +fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) { + f(n); + if let Some(m) = &mut n.mask { + each_mut(Arc::make_mut(&mut m.art), f); + } + for c in n.children_mut().into_iter().flatten() { + each_mut(Arc::make_mut(c), f); + } +} + +/// `n`'s references to images, symbols, patterns and placed files prefixed with `prefix`. Colours +/// keep their swatch links (a spot colour stays a plate, one with the parent's spot of that name). +fn rename(n: &mut Node, prefix: &str) { + let paint = |p: &mut Paint| { + if let Paint::Pattern { pattern, .. } = p { + *pattern = format!("{prefix}{pattern}"); + } + }; + each_mut(n, &mut |n| { + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(f) => paint(&mut f.paint), + AppearanceItem::Stroke(s) => paint(&mut s.paint), + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + paint(&mut r.style.fill); + paint(&mut r.style.stroke); + } + } + NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key), + NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key), + NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"), + _ => {} + } + }); +} + +impl Document { + /// This document ready to export its placed documents: their files read (the file read again + /// when only the preview is stored, else the preview), and the resources their art uses added + /// under names of their own ([`RESOURCE_PREFIX`]); the same for the placed documents in that + /// art, up to [`MAX_DEPTH`] deep. Borrowed when there is none. (The renderer draws them from + /// [`read`] instead.) + pub fn with_placed_art(&self) -> Cow<'_, Document> { + let mut queue: Vec<(PlacedDocument, usize)> = vec![]; + let mut seen = BTreeSet::new(); + self.visit_placed(|_, p| { + if seen.insert(p.key.clone()) { + queue.push((p.clone(), 0)); + } + }); + if queue.is_empty() { + return Cow::Borrowed(self); + } + let mut d = self.clone(); + let mut found: Vec> = vec![]; + while let Some((p, depth)) = queue.pop() { + let Some(blob) = d.images.get(&p.key).cloned() else { continue }; + let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None }; + if full.and_then(|b| read(&p, &b)).is_none() { + let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; + if let Some(bytes) = preview { + read_preview(&p, &bytes); + } + } + // What the art (`PlacedDocument::art`) will show. + let Some(e) = read_cached(&p) else { continue }; + let prefix = prefix(&p.key); + add_resources(&mut d, &e.doc, &prefix); + // The placed documents in that art, under their names here. + let inner = std::iter::once(&e.art).chain(e.doc.symbols.iter().map(|s| &s.art)).chain(e.doc.patterns.iter().flat_map(|p| &p.art)); + for root in inner { + walk_all(root, &mut |n| { + if let NodeKind::PlacedDocument(q) = &n.kind { + let key = format!("{prefix}{}", q.key); + if seen.insert(key.clone()) { + queue.push((PlacedDocument { key, ..(**q).clone() }, depth + 1)); + } + } + }); + } + found.push(e); + } + // Read last, so all stay read while the art is made (see `PlacedDocument::art`). + { + let mut c = cache(); + for e in &found { + c.get(&e.key, e.preview, None); + } + } + Cow::Owned(d) + } + + /// Drop the resources [`Self::with_placed_art`] added under its names (what a save leaves out). + pub fn drop_placed_resources(&mut self) { + self.symbols.retain(|s| !s.name.starts_with(RESOURCE_PREFIX)); + self.patterns.retain(|p| !p.name.starts_with(RESOURCE_PREFIX)); + self.images.retain(|k, _| !k.starts_with(RESOURCE_PREFIX)); + } +} + +/// The resources of `src` (a read file's) added to `d` under `prefix`: symbols, patterns, images; +/// swatches by name, when `d` has none of that name. +fn add_resources(d: &mut Document, src: &Document, prefix: &str) { + for s in &src.symbols { + let name = format!("{prefix}{}", s.name); + if !d.symbols.iter().any(|x| x.name == name) { + let mut s = s.clone(); + rename(Arc::make_mut(&mut s.art), prefix); + d.symbols.push(Symbol { name, ..s }); + } + } + for p in &src.patterns { + let name = format!("{prefix}{}", p.name); + if d.pattern(&name).is_none() { + let mut p = p.clone(); + p.art.iter_mut().for_each(|a| rename(Arc::make_mut(a), prefix)); + d.patterns.push(PatternDef { name, ..p }); + } + } + for (k, b) in &src.images { + d.images.entry(format!("{prefix}{k}")).or_insert_with(|| b.clone()); + } + for w in &src.swatches { + if d.swatch(&w.name).is_none() { + d.swatches.push(w.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NodeId; + + fn placed(key: &str) -> PlacedDocument { + PlacedDocument { + link: LinkInfo::new("/art/logo.vectorcraft"), + key: key.into(), + bounding: false, + width: 80.0, + height: 40.0, + xf: Affine::translate((10.0, 10.0)), + placement: Default::default(), + } + } + + /// Whatever the bytes: a 40×20 artboard showing a symbol instance and, when the bytes say + /// `nest`, a placed document of key `inner`. + fn stub_loader(_: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let mut d = Document::new(40.0, 20.0); + d.layers.clear(); + let art = Node::new( + NodeId(50), + NodeKind::Image(ImageObject { key: "pic".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }), + ); + d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) }); + d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3])); + let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))]; + if bytes.starts_with(b"nest") { + d.images.insert("inner".into(), ImageBlob::new("application/json", b"leaf".to_vec())); + let inner = PlacedDocument { key: "inner".into(), width: 10.0, height: 10.0, xf: Affine::IDENTITY, ..placed("inner") }; + children.push(Arc::new(Node::new(NodeId(52), NodeKind::PlacedDocument(Box::new(inner))))); + } + Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) + } + + #[test] + fn round_trips_through_json() { + let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1")))); + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(v["kind"]["type"], "placeddocument"); + assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft"); + let back: Node = serde_json::from_value(v).unwrap(); + assert_eq!(back, n); + assert_eq!(key_for("img00", 2, true), "img00-2b"); + } + + #[test] + fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() { + set_loader(stub_loader); + let mut d = Document::new(200.0, 200.0); + d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) }); + d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec())); + let p = placed("doc1-1"); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(p.clone())))).unwrap(); + assert!(d.has_placed()); + let prepared = d.with_placed_art(); + let names: Vec<&str> = prepared.symbols.iter().map(|s| s.name.as_str()).collect(); + // The placed document inside is read too, its resources under its own names. + assert_eq!(names, ["Star", "placed:doc1-1/Star", "placed:placed:doc1-1/inner/Star"]); + assert!(prepared.images.contains_key("placed:doc1-1/pic")); + assert_eq!(d.symbols.len(), 1, "the document itself is unchanged"); + // The art: a group of the drawing, its resources renamed. + let n = d.node(NodeId(7)).unwrap(); + let art = p.art(n); + let [g] = &art[..] else { panic!("{art:?}") }; + let mut refs = vec![]; + g.walk(&mut |c| match &c.kind { + NodeKind::SymbolInstance { symbol, .. } => refs.push(symbol.clone()), + NodeKind::PlacedDocument(q) => { + // Its 10×10 box at the file's origin, scaled ×2 into the box at (10, 10). + assert_eq!(q.bounds(), Rect::new(10.0, 10.0, 30.0, 30.0)); + refs.push(q.key.clone()) + } + _ => {} + }); + assert_eq!(refs, ["placed:doc1-1/Star", "placed:doc1-1/inner"]); + // For older apps: plain groups all the way down. + let mut older = prepared.clone().into_owned(); + older.placed_as_groups(); + assert!(!older.has_placed()); + let mut saved = prepared.into_owned(); + saved.drop_placed_resources(); + assert_eq!((saved.symbols.len(), saved.images.len()), (1, 1)); + } + + #[test] + fn more_files_than_the_cache_holds_all_draw() { + set_loader(stub_loader); + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let n = CACHE_SIZE + 10; + for i in 0..n { + let key = format!("many{i}-1"); + d.images.insert(key.clone(), ImageBlob::new("application/json", format!("doc {i}").into_bytes())); + d.insert(Some(layer), i, Node::new(NodeId(100 + i as u64), NodeKind::PlacedDocument(Box::new(placed(&key))))).unwrap(); + } + let _prepared = d.with_placed_art(); + let mut empty = 0; + d.visit_placed(|n, p| empty += usize::from(p.art(n).is_empty())); + assert_eq!(empty, 0, "every file of the document is kept read"); + } + + #[test] + fn a_file_that_cant_be_read_shows_its_preview() { + set_loader(stub_loader); + let mut png = vec![]; + image::RgbaImage::from_pixel(8, 4, image::Rgba([0, 200, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let mut blob = ImageBlob::new("image/png", png); + blob.proxy = Some(blob.bytes.clone()); + let mut d = Document::new(200.0, 200.0); + d.images.insert("gone-1".into(), blob); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, Node::new(NodeId(7), NodeKind::PlacedDocument(Box::new(placed("gone-1"))))).unwrap(); + // No file reader: only the preview can be had. + let prepared = d.with_placed_art(); + assert!(prepared.images.contains_key("placed:gone-1/preview")); + let n = d.node(NodeId(7)).unwrap(); + let NodeKind::PlacedDocument(p) = &n.kind else { panic!("not a placed document") }; + let art = p.art(n); + assert_eq!(art.first().and_then(Node::geometric_bounds), Some(Rect::new(10.0, 10.0, 90.0, 50.0))); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 893d2125..f1a08bee 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -324,14 +324,19 @@ pub fn bake_appearance(n: &Node) -> Option { Some(m) } -/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the -/// document has none (export it as is). +/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the +/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when +/// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { - if !doc.layers.iter().any(|l| needs_bake(l)) { - return None; + let placed = doc.with_placed_art(); + if !placed.layers.iter().any(|l| needs_bake(l)) { + return match placed { + std::borrow::Cow::Owned(d) => Some(d), + std::borrow::Cow::Borrowed(_) => None, + }; } - let mut d = doc.clone(); - let layers = doc.layers.clone(); + let mut d = placed.into_owned(); + let layers = d.layers.clone(); d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); Some(d) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0efcb90e..d39fbef4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -26,6 +26,7 @@ pub fn needs_outline(n: &Node) -> bool { | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) ) } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b118c842..e966c644 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -127,6 +127,7 @@ fn node_refs(n: &Node, f: Visit) { } } NodeKind::Image(im) => f(Ref::Name(Res::Image, &im.key)), + NodeKind::PlacedDocument(pl) => f(Ref::Name(Res::Image, &pl.key)), NodeKind::SymbolInstance { symbol, .. } => f(Ref::Name(Res::Symbol, symbol)), _ => {} } @@ -444,6 +445,7 @@ impl Relink { } } NodeKind::Image(im) => self.name(Res::Image, &mut im.key), + NodeKind::PlacedDocument(pl) => self.name(Res::Image, &mut pl.key), NodeKind::SymbolInstance { symbol, .. } => self.name(Res::Symbol, symbol), _ => {} } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 6654df57..34027e70 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -418,6 +418,7 @@ fn not_liquified(n: &Node) -> Option<&'static str> { NodeKind::Mesh(_) => "meshes", NodeKind::Envelope { .. } => "envelopes", NodeKind::Repeat(_) => "repeats", + NodeKind::PlacedDocument(_) => "placed documents", NodeKind::Blend { .. } => "blends", _ => return None, }) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 29a0d744..05cf1bc3 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -49,13 +49,14 @@ pub const CATEGORIES: [(&str, &str); 11] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 16] = [ +const OBJECT_LABELS: [(&str, &str); 17] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), ("clipGroups", "Clipping Masks"), ("textObjects", "Text Objects"), ("images", "Images"), + ("placedDocuments", "Placed Documents"), ("symbolInstances", "Symbol Instances"), ("gradients", "Gradient Objects"), ("patterns", "Pattern Objects"), @@ -252,6 +253,7 @@ fn info(s: &mut Session, p: &Value) -> Result { NodeKind::Envelope { .. } => Some("envelopes"), NodeKind::Mesh(_) => Some("meshes"), NodeKind::Repeat(_) => Some("repeats"), + NodeKind::PlacedDocument(_) => Some("placedDocuments"), }; if let Some(k) = k { *counts.entry(k).or_default() += 1; diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 7661793c..6def788b 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1185,6 +1185,8 @@ impl Scratch { let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g)) } + // A placed document stays one: its art's resources aren't the document's. + NodeKind::PlacedDocument(_) => None, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook)))) diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 0c154ac6..99f4012a 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -291,7 +291,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index c6395c5c..e49c7e0f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -23,7 +23,7 @@ //! Versions: v1 wrote anchors as `{p: {x, y}, in: {x, y}, out: {x, y}, kind}`; v2 as //! `{p: [x, y], in?, out?, kind?}` with default-valued fields left out; v3 keeps the assigned colour //! profiles in `document.color_profiles` (before: `document.unknown.colorProfiles`) and may be -//! compressed; v4 may keep large data (images, the PDF, profiles of at least +//! compressed; v4 may keep large data (images, placed documents, the PDF, profiles of at least //! [`INLINE_MAX`] bytes) after the JSON instead of in it: the JSON is followed by [`BLOB_MAGIC`] and //! the bytes, and such an entry says where they are (`"blob": [offset, length]`) instead of giving //! `data`. Every version loads, and [`save_with`] writes any of them for older apps (under the @@ -137,6 +137,11 @@ impl Blobs { } } +/// The MIME type of a preview's bytes (JPEG or PNG). +fn preview_mime(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME } +} + /// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON /// value. fn split_tail(text: &[u8]) -> (&[u8], &[u8]) { @@ -232,6 +237,14 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError o.check()?; let pretty = o.pretty && !o.compress; let mut d = doc.without_edit_modes().into_owned(); + // Older apps don't know placed documents: they get the art the objects show, with its + // resources. Otherwise resources only exporting adds are never saved. + if o.version < VERSION && d.has_placed() { + d = d.with_placed_art().into_owned(); + d.placed_as_groups(); + } else { + d.drop_placed_resources(); + } let linked = d.linked_only_images(); // The blobs go in the file's `images` (only those the document uses). let blobs = std::mem::take(&mut d.images); @@ -256,7 +269,7 @@ pub fn save_with(doc: &Document, o: &SaveOptions) -> Result, FormatError // Include Linked Files keeps the file's bytes whenever they are loaded. let preview_only = linked.contains(k) && (!o.include_linked || b.is_proxy()); let image = match b.proxy.as_ref().filter(|_| preview_only) { - Some(p) => out_blobs.image(vectorcraft_doc::links::PROXY_MIME, p, true), + Some(p) => out_blobs.image(preview_mime(p), p, true), None => out_blobs.image(&b.mime, &b.bytes, false), }; (k.as_str(), image) diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 3734f27c..b19545b6 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -189,7 +189,7 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 239c935b..548c33b9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -982,7 +982,7 @@ impl Exporter<'_> { } } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(s, c, page, false); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f548d147..a6e43538 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,7 @@ mod ink; mod live; mod paint; mod pattern; +pub mod placed_document; pub mod proof; use std::collections::HashMap; @@ -580,6 +581,23 @@ impl Renderer { self.render_node_thumbnail(doc, doc.node(id)?, size, None) } + /// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`. + pub fn render_node(&mut self, doc: &Document, n: &Arc, w: u16, h: u16, view: Affine) -> Option { + let inv = view.inverse(); + let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let mut ctx = single_threaded_context(w, h); + let opts = RenderOptions::default(); + let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display }; + (self.knockout, self.nested, self.backdrop) = (false, 0, None); + self.clip_paths.clear(); + self.draw_arc(&mut ctx, &frame, n); + ctx.flush(); + let mut pm = Pixmap::new(w, h); + ctx.render(&mut pm, &mut self.resources); + Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) + } + /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into /// `size`×`size` pixels, optionally over a solid premultiplied background. pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { @@ -916,7 +934,9 @@ impl Renderer { self.draw_node(ctx, f, &art, true); } } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + self.draw_live_node(ctx, f, n) + } } } @@ -1649,6 +1669,8 @@ mod tests_layeropts; #[cfg(test)] mod tests_objectfx; #[cfg(test)] +mod tests_placed_document; +#[cfg(test)] mod tests_setup; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index f1b5fb3d..5b870d2d 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -143,6 +143,7 @@ impl Renderer { /// Draw the evaluated content of a live node (no transparency group). pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { match &a.kind { + NodeKind::PlacedDocument(p) => self.draw_placed(ctx, f, p), NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => { let items = self.live_expanded(f.doc, a, cache); diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs new file mode 100644 index 00000000..8955b6b0 --- /dev/null +++ b/crates/render/src/placed_document.rs @@ -0,0 +1,88 @@ +//! Drawing placed documents ([`PlacedDocument`]): the art read from the file +//! ([`vectorcraft_doc::placed_document::read`]) drawn with its own resources, through a view that +//! maps its artboard into the object's box (nothing is copied into the document). +//! +//! A document opened with only its placed documents' previews +//! ([`vectorcraft_doc::ImageBlob::is_proxy`]) shows them until the file is at hand +//! ([`vectorcraft_doc::placed_document::full_bytes_cached`]), and for good when it can't be found. Placed documents inside a placed document are read from their +//! files, up to [`MAX_DEPTH`] deep; deeper ones show their previews. + +use std::cell::Cell; +use std::sync::Arc; + +use vectorcraft_doc::placed_document::MAX_DEPTH; +use vectorcraft_doc::{Node, PlacedDocument}; +use vectorcraft_geom::Affine; +use vello_cpu::RenderContext; + +use crate::{Frame, Renderer}; + +thread_local! { + /// How deep inside placed documents this thread is drawing. + static DEPTH: Cell = const { Cell::new(0) }; +} + +/// While alive: drawing inside a placed document, one level deeper. +struct Inside; + +impl Inside { + fn enter() -> Self { + DEPTH.with(|d| d.set(d.get() + 1)); + Inside + } +} + +impl Drop for Inside { + fn drop(&mut self) { + DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } +} + +fn depth() -> usize { + DEPTH.with(Cell::get) +} + +/// Render `art` of a placed document's file (`doc`, its resources) into `w`×`h` transparent +/// pixels through `view`, the placed documents inside it one level deeper. +pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Arc, w: u16, h: u16, view: Affine) -> Option { + let _inside = Inside::enter(); + r.render_node(doc, art, w, h, view) +} + +/// The mean scale of `a`. +fn mean_scale(a: Affine) -> f64 { + a.determinant().abs().sqrt() +} + +impl Renderer { + /// Draw placed document `p` in frame `f`. + pub(crate) fn draw_placed(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) { + let Some(blob) = f.doc.images.get(&p.key) else { return }; + // The file's bytes: stored, read again already, or (inside another placed document, while + // not too deep) read again now. + let full = match depth() { + 0 => vectorcraft_doc::placed_document::full_bytes_cached(p, blob), + d if d < MAX_DEPTH => vectorcraft_doc::placed_document::full_bytes(p, blob), + _ => None, + }; + let read = full.and_then(|bytes| vectorcraft_doc::placed_document::read(p, &bytes)); + let Some(e) = read else { + // Only the preview: drawn scaled into the box. + if blob.is_proxy() + && let Some(im) = p.preview_image(&blob.bytes) + { + self.draw_image(ctx, f, &im); + } + return; + }; + let Some(m) = p.art_xf(e.frame) else { return }; + let k = mean_scale(m); + if !k.is_finite() || k <= 1e-12 { + return; + } + // The art in its own coordinates, with its own resources. + let inner = Frame { doc: &e.doc, view: f.view * m, visible: m.inverse().transform_rect_bbox(f.visible), px: f.px / k, ..*f }; + let _inside = Inside::enter(); + self.draw_arc(ctx, &inner, &e.art); + } +} diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs new file mode 100644 index 00000000..0224fa39 --- /dev/null +++ b/crates/render/src/tests_placed_document.rs @@ -0,0 +1,83 @@ +//! Placed documents drawn exactly: with their own art, as their previews when that is all there +//! is, and a few levels deep when they place themselves. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument}; +use vectorcraft_geom::shapes; + +use super::*; + +/// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also +/// places itself (the same key) in its middle. +fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let mut d = Document::new(40.0, 20.0); + d.layers.clear(); + let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); + let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))]; + if bytes.starts_with(b"loop") { + d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec())); + let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() }; + children.push(Arc::new(Node::new(NodeId(2), NodeKind::PlacedDocument(Box::new(me))))); + } + Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) +} + +/// A 100×100 document showing one placed file (`bytes` under `key`) in the box (10, 10)–(90, 50). +fn doc_with(key: &str, bytes: &[u8]) -> Document { + vectorcraft_doc::placed_document::set_loader(blue_page); + let mut d = Document::new(100.0, 100.0); + d.images.insert(key.into(), ImageBlob::new("application/json", bytes.to_vec())); + let p = PlacedDocument { + link: LinkInfo::new("/art/card.vectorcraft"), + key: key.into(), + bounding: false, + width: 40.0, + height: 20.0, + xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0), + placement: Default::default(), + }; + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(NodeId(50), NodeKind::PlacedDocument(Box::new(p)))).unwrap(); + d +} + +fn doc(key: &str) -> Document { + doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes()) +} + +fn render(d: &Document) -> Rendered { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts) +} + +#[test] +fn a_placed_document_draws_exactly_with_its_own_art() { + let d = doc("exact-1"); + let r = render(&d); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box"); + assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside"); +} + +#[test] +fn a_placed_document_kept_as_its_preview_draws_the_preview() { + let mut d = doc("preview-1"); + // Only the preview (a 20×10 green PNG) is there, and the file can't be read here. + let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([0, 200, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut blob = ImageBlob::new("image/png", png); + blob.proxy = Some(blob.bytes.clone()); + d.images.insert("preview-1".into(), blob); + let exact = render(&d); + assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box"); + assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]); +} + +#[test] +fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() { + let d = doc_with("loop-1", b"loop"); + let t = std::time::Instant::now(); + let r = render(&d); + assert!(t.elapsed().as_secs() < 10); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index df5617bc..6eec850c 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -343,7 +343,7 @@ impl Reuse { } } match &n.kind { - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { (self.turns, self.scales) = (false, false) } NodeKind::SymbolInstance { symbol, .. } => { @@ -1357,7 +1357,7 @@ impl Writer<'_> { (self.xf, self.instance_xf) = saved; } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.share_images(&g); self.node_body(&g); diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index 82b90b89..a0821027 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -98,11 +98,16 @@ fn native_roundtrip(doc: &Document) -> Result<(Value, Value), String> { } let mut lost = None; back.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && doc.images.contains_key(&im.key) - && !back.images.contains_key(&im.key) + let key = match &n.kind { + NodeKind::Image(im) => Some(&im.key), + NodeKind::PlacedDocument(p) => Some(&p.key), + _ => None, + }; + if let Some(key) = key + && doc.images.contains_key(key) + && !back.images.contains_key(key) { - lost = Some(im.key.clone()); + lost = Some(key.clone()); } }); if let Some(k) = lost { From afc45cdeb872a08c16a89f4989ce52faac3e5e29 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:54:48 -0700 Subject: [PATCH 09/74] =?UTF-8?q?Placed=20documents:=20File=20=E2=80=BA=20?= =?UTF-8?q?Place=20links=20a=20VectorCraft=20document=20by=20default;=20ou?= =?UTF-8?q?tput=20reads=20its=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/fileio/encode.rs | 16 ++ crates/engine/src/cmd/fileio/native.rs | 7 + crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/place/adopt.rs | 3 +- crates/engine/src/cmd/place/document.rs | 246 ++++++++++++++++++++ crates/engine/src/cmd/place/mod.rs | 62 +++-- crates/engine/src/cmd/print.rs | 3 + crates/engine/src/lib.rs | 5 + crates/engine/src/tests_links.rs | 3 +- crates/engine/src/tests_placed_document.rs | 249 +++++++++++++++++++++ 10 files changed, 575 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/cmd/place/document.rs create mode 100644 crates/engine/src/tests_placed_document.rs diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index fc0b564e..202d8867 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -364,6 +364,22 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<( /// [`encode`], with every file the export writes. pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { + // Placed documents output their files' art (a native file keeps what it keeps: see + // `native::encode`). + if !matches!(format, "vectorcraft" | "template") { + let (full, warnings) = crate::cmd::place::document::full_documents(doc); + if !warnings.is_empty() { + let mut enc = encode_all_of(&full, format, p)?; + enc.warnings.extend(warnings); + return Ok(enc); + } + return encode_all_of(&full, format, p); + } + encode_all_of(doc, format, p) +} + +/// [`encode_all`] of `doc` as it is. +fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let mut enc = encode_files(doc, f, p)?; // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index dda39386..cb2d9511 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -127,6 +127,13 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> /// The `.vectorcraft` file of `doc` with the options in `p`. pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { let (mut so, extras) = save_options(cmd, f, doc, p)?; + // Include Linked Files keeps placed documents' files; older versions get their art. + let full = if so.include_linked || so.version < vectorcraft_format::VERSION { + crate::cmd::place::document::full_documents(doc).0 + } else { + std::borrow::Cow::Borrowed(doc) + }; + let doc = &*full; if extras.preview { so.preview = preview_png(doc)?; } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f4b6a990..c0115bcf 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -48,7 +48,7 @@ mod pathops; mod patterncmds; pub mod pdfcmds; pub mod perspgrid; -mod place; +pub(crate) mod place; pub mod plugin; pub mod prefscmds; pub mod print; diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index ee4205d6..928a4baf 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -67,6 +67,7 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { } } NodeKind::Image(im) => f(Ref::Image(&mut im.key)), + NodeKind::PlacedDocument(pl) => f(Ref::Image(&mut pl.key)), NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)), _ => {} } @@ -129,7 +130,7 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> { /// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point /// `nodes` at the names they got. -pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { +pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { let mut used = Used::default(); for n in nodes.iter_mut() { refs(n, &mut |r| used.note(r)); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs new file mode 100644 index 00000000..624259d0 --- /dev/null +++ b/crates/engine/src/cmd/place/document.rs @@ -0,0 +1,246 @@ +//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one +//! locked object showing an artboard of the document, a [`PlacedDocument`]. +//! +//! The object keeps the file's bytes (in the document's images, like image data) and the link; the +//! art it shows is read from them the way placing the file without Link reads it +//! ([`document_art`]: the artboard's art, with the file's own linked images found). The Links +//! commands ([`super::super::links`]) tell when the file changed and read it again. +//! +//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept +//! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked +//! Files), as for linked images. The file is read again when it is needed ([`read_file_again`]: +//! output, or the screen zoomed past the preview), while it is unchanged, and output falls back to +//! the preview with a warning when it can't be ([`full_documents`]). + +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::sync::Arc; + +use vectorcraft_doc::links::hash_bytes; +use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for}; +use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions}; +use vectorcraft_geom::{Affine, Rect}; + +use super::super::fileio; +use super::super::*; + +/// The largest file placed (bytes). +pub(crate) const MAX_FILE: usize = 256 << 20; +/// The format ids of VectorCraft documents. +const NATIVE: [&str; 2] = ["vectorcraft", "template"]; +/// The type a placed file's bytes are stored with. +const MIME: &str = "application/json"; + +/// Is `name` with `bytes` a VectorCraft document? +pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool { + fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id)) +} + +/// An artboard of a VectorCraft document, read as art to place. +pub(crate) struct DocumentArt { + /// The document, its layers taken: the art's resources. + pub doc: Document, + /// The art on the artboard (guides, hidden and template layers left out). + pub nodes: Vec, + /// The artboard the art is clipped to (`None`: the art's bounds are shown). + pub clip: Option, + /// What is shown: the artboard, or the art's bounds. + pub frame: Rect, + pub warnings: Vec, +} + +/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when +/// there is one, so its linked images are found), clipped to the artboard unless `bounding`. +pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { + let file = fileio::file_name(name); + if !is_native(name, bytes) { + return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document"))); + } + if bytes.len() > MAX_FILE { + return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20))); + } + let mut l = fileio::load(name, bytes)?; + // Its linked images show their files; its placed documents stay as they are. + super::super::links::resolve(&mut l.doc, path, false); + l.doc.drop_edit_modes(); + let n = l.doc.artboards.len(); + let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); + let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?; + let layers = std::mem::take(&mut l.doc.layers); + let nodes = super::on_artboard(&layers, board); + let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) }; + let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?; + Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) +} + +/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads). +fn group(nodes: Vec, clip: Option) -> Node { + let mut children: Vec> = nodes.into_iter().map(Arc::new).collect(); + if let Some(r) = clip { + children.insert(0, Arc::new(super::clip_path(NodeId(0), r))); + } + Node::new(NodeId(0), NodeKind::Group { children, clip: clip.is_some() }) +} + +/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents +/// ([`vectorcraft_doc::placed_document::set_loader`]). +pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?; + Some((a.doc, group(a.nodes, a.clip), a.frame)) +} + +/// `p`'s file read again, while it is the file it was read from (how the document model gets it +/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]). +pub(crate) fn read_file_again(p: &PlacedDocument) -> Option> { + let bytes = fileio::read_file(&p.link.path).ok()?; + if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) { + return None; + } + Some(bytes) +} + +/// A VectorCraft document read for placing linked. +#[derive(Clone)] +pub(crate) struct Source { + /// The file's bytes, with their preview. + pub blob: ImageBlob, + /// The blob's key ([`key_for`]). + pub key: String, + /// What is shown, in the file's coordinates: the artboard or the art's bounds. + pub frame: Rect, + pub warnings: Vec, +} + +/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing +/// artboard `page` (its art's bounds when `bounding`). +pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { + // The same document placed the same way is read once (placing it again is free). + let key = key_for(&hash_bytes(bytes), page, bounding); + if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() { + return Ok(s); + } + let a = document_art(name, bytes, path, page, bounding, cmd)?; + let mut blob = ImageBlob::new(MIME, bytes.to_vec()); + let shown = PlacedDocument { + link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) }, + key: key.clone(), + bounding, + width: a.frame.width(), + height: a.frame.height(), + xf: Affine::IDENTITY, + placement: PlacementOptions::default(), + }; + let art = group(a.nodes, a.clip); + blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); + // Drawing it needn't read it again. + vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); + let s = Source { blob, key, frame: a.frame, warnings: a.warnings }; + let mut m = lock_memo(); + if m.len() >= MEMO_SIZE { + m.remove(0); + } + m.push(s.clone()); + Ok(s) +} + +/// The most files remembered. +const MEMO_SIZE: usize = 32; + +/// Files read lately for placing, by key. +static MEMO: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn lock_memo() -> std::sync::MutexGuard<'static, Vec> { + // Entries are only pushed and removed: a panic while held leaves it whole. + MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The preview of `art` (of document `doc`, showing `frame`): [`PREVIEW_PX_PER_PT`], at most +/// [`PREVIEW_MAX`] pixels a side; JPEG when opaque (inside its outer pixels), else PNG. +fn preview(doc: &Document, art: &Node, frame: Rect) -> Option> { + let k = PREVIEW_PX_PER_PT.min(PREVIEW_MAX as f64 / frame.width().max(frame.height()).max(1e-9)); + let side = |v: f64| (v * k).round().clamp(1.0, PREVIEW_MAX as f64) as u32; + let (w, h) = (side(frame.width()), side(frame.height())); + let view = Affine::scale_non_uniform(w as f64 / frame.width().max(1e-9), h as f64 / frame.height().max(1e-9)) + * Affine::translate((-frame.x0, -frame.y0)); + let mut r = vectorcraft_render::Renderer::new(); + let img = vectorcraft_render::placed_document::render_inside(&mut r, doc, &Arc::new(art.clone()), w as u16, h as u16, view)?; + // Premultiplied → straight. + let px = img + .pixels + .as_chunks::<4>() + .0 + .iter() + .flat_map(|&[r, g, b, a]| { + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + [un(r), un(g), un(b), a] + }) + .collect(); + let rgba = image::RgbaImage::from_raw(w, h, px)?; + let mut out = vec![]; + // Opaque but for the edge's anti-aliasing (an artboard drawn to whole pixels) and compositing's + // rounding: JPEG. + let inner = |x: u32, y: u32| x > 0 && y > 0 && x + 1 < w && y + 1 < h; + if rgba.enumerate_pixels().all(|(x, y, p)| p[3] >= 250 || !inner(x, y)) { + let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&rgb).ok()?; + } else { + rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + } + Some(out) +} + +/// `s` stored in `d` (objects showing the same thing share one blob). +pub(crate) fn store(d: &mut Document, s: &Source) { + d.images.entry(s.key.clone()).or_insert_with(|| s.blob.clone()); +} + +/// A new placed document of `d` showing `s` at 100%, where its art lies in the file. +pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) -> Node { + store(d, s); + let placed = PlacedDocument { + link, + key: s.key.clone(), + bounding, + width: s.frame.width(), + height: s.frame.height(), + xf: Affine::translate((s.frame.x0, s.frame.y0)), + placement: PlacementOptions::default(), + }; + Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))) +} + +/// `d` for output: the placed documents that keep only their previews get their files back, +/// else (their files gone or changed) output as their previews, with a warning. +pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { + let mut need: Vec = vec![]; + d.visit_placed(|_, p| { + if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { + need.push(p.clone()); + } + }); + if need.is_empty() { + return (Cow::Borrowed(d), vec![]); + } + let mut out = d.clone(); + let mut previews = BTreeSet::new(); + for p in need { + let Some(blob) = d.images.get(&p.key) else { continue }; + match vectorcraft_doc::placed_document::full_bytes(&p, blob) { + Some(bytes) => { + out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() }); + } + None => { + previews.insert(p.key); + } + } + } + let mut warnings = vec![]; + if !previews.is_empty() { + warnings.push(format!( + "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", + previews.len() + )); + out.placed_as_previews(&previews); + } + (Cow::Owned(out), warnings) +} diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index c760001d..68e4a58c 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -16,13 +16,17 @@ //! (`crop: "bounding"`). //! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`, //! [`text`]). +//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document: +//! one locked object showing the artboard `page`, linked to the file and read again when it +//! changes ([`document`]); without Link, an editable copy of its art. //! //! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was, //! with its transform; Template puts it on a new template layer. The images, symbols, patterns and //! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the //! `place` tool) with several files, which emits one `file.place` per click or drag. -mod adopt; +pub(crate) mod adopt; +pub(crate) mod document; mod text; use std::sync::Arc; @@ -53,7 +57,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -94,6 +98,9 @@ enum Art { Image(RasterImage), /// A text file's text, placed as area type filling the natural box. Text(String), + /// A VectorCraft document placed linked: the file read, the link, and whether the art's + /// bounds (not the artboard) are shown. + Document(document::Source, LinkInfo, bool), Vector { /// The loaded document: the source of the art's resources. src: Box, @@ -157,6 +164,19 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { )); } let crop = opts.crop != CropTo::Bounding; + // A VectorCraft document: placed linked from its file, else as an editable copy. + if matches!(format.id, "vectorcraft" | "template") { + let page = page as u32; + if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) { + let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?; + let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) }; + let (natural, warnings) = (s.frame, s.warnings.clone()); + return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None }); + } + let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?; + let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip }; + return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None }); + } if format.raster { let img = fileio::raster_image(&src.bytes)?; let (sx, sy) = pt_per_px(img.ppi); @@ -176,7 +196,7 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { // Of the open options, only a DXF drawing's apply (the colour mode stays the file's). let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() }; let mut l = fileio::load_with(src.name, &src.bytes, &o)?; - // Linked images (a native document's) show their files, found from its folder. + // Linked images show their files, found from its folder. super::links::resolve(&mut l.doc, src.path, false); (l.doc, l.warnings) } @@ -190,22 +210,31 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { // The page placed is a PDF import's only one. let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); - let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| { - let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" }; - bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)")) - })?; - // A PDF import has one layer per page; a native document's (or an EPS file's) art is - // whatever lies on the artboard. - let pages: Vec<&Arc> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() }; - let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); - let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)); - (nodes, crop.then_some(board)) + let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; + // A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard. + let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] }; + (on_artboard(pages, board), crop.then_some(board)) }; let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds()))); let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board }) } +/// The placeable art of `layers` that lies on `board`. +fn on_artboard(layers: &[Arc], board: Rect) -> Vec { + let on_board = |c: &&Arc| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin())); + art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board)) +} + +/// A clipping path of rectangle `r`, with id `id`. +fn clip_path(id: NodeId, r: Rect) -> Node { + let mut c = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut c.kind { + *clipping = true; + } + c +} + /// A visible, non-template layer. fn placeable(l: &Node) -> bool { l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. }) @@ -243,15 +272,12 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { Node::new(d.alloc_id(), NodeKind::Image(im)) } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))), + Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), Art::Vector { src, mut nodes, clip } => { adopt::adopt(d, &src, &mut nodes); let mut children: Vec> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); if let Some(r) = clip { - let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = true; - } - children.insert(0, Arc::new(c)); + children.insert(0, Arc::new(clip_path(d.alloc_id(), r))); } Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() }) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index e852f80b..a5ecac06 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -118,6 +118,9 @@ fn print(s: &mut Session, p: &Value) -> Result { let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium"); set.advanced.flattener_preset = preset.to_string(); } + // Placed documents print their files' art. + let (full, _) = super::place::document::full_documents(doc); + let doc = &*full; // Raster effects print as images at the document's raster effects resolution. let flat = super::rasterfx::flatten_raster_effects(doc); let doc = flat.as_ref().unwrap_or(doc); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d615abcb..874cb74d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -801,6 +801,9 @@ impl Default for Session { impl Session { pub fn new() -> Self { + // Placed documents are read by the native format's loader. + vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); + vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); Self { docs: vec![], active: None, @@ -1450,6 +1453,8 @@ mod tests_perspgrid; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_placed_document; +#[cfg(test)] mod tests_plugins; #[cfg(test)] mod tests_prefs; diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 5585ef99..da8a2e53 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -320,7 +320,8 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() { let doc_path = dir.file("part.vectorcraft"); save(&mut s, &doc_path); let mut s = session(); - s.execute("file.place", &json!({"path": doc_path})).unwrap(); + // As an editable copy (placed linked, it stays one locked object). + s.execute("file.place", &json!({"path": doc_path, "link": false})).unwrap(); let doc = &s.doc().unwrap().doc; let mut placed = vec![]; doc.visit_images(|_, im| placed.push(im.clone())); diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs new file mode 100644 index 00000000..da0f6b90 --- /dev/null +++ b/crates/engine/src/tests_placed_document.rs @@ -0,0 +1,249 @@ +//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read +//! from a file), drawn and output as vectors, and saved with its preview. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument}; +use vectorcraft_geom::{Rect, shapes}; + +use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write}; +use super::*; + +/// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more +/// rectangles, which change the file's size). +fn source(path: &str, w: f64, h: f64, rgb: [u8; 3], extra: usize) { + let mut d = Document::new(w, h); + let layer = d.layers[0].id; + let [r, g, b] = rgb; + for i in 0..=extra { + let id = d.alloc_id(); + let look = Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0); + d.insert(Some(layer), i, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, w, h)), look)).unwrap(); + } + write(path, &vectorcraft_format::save_file(&d)); +} + +/// A document drawing symbol `Star` (a blue 100×50 rectangle) at `path`, titled `title` (tests +/// that remove the file give it content of its own: files read are cached by content). +fn star_source(path: &str, title: &str) { + let mut d = Document::new(100.0, 50.0); + d.title = title.into(); + let layer = d.layers[0].id; + let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE)); + d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + write(path, &vectorcraft_format::save_file(&d)); +} + +fn look(c: [u8; 3]) -> Appearance { + Appearance::basic(Paint::solid(Color::rgb8(c[0], c[1], c[2])), Paint::None, 0.0) +} + +/// The active document gets a red symbol named `Star` of its own. +fn parent_star(s: &mut Session) { + let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .symbols + .push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) }); +} + +/// Place `path` (linked: the default), centred on the artboard → its id. +fn place(s: &mut Session, path: &str) -> NodeId { + let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + NodeId(r["ids"][0].as_u64().unwrap()) +} + +fn placed(s: &Session, id: NodeId) -> PlacedDocument { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::PlacedDocument(p) => (**p).clone(), + k => panic!("not a placed document: {k:?}"), + } +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn decode(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// Images and paths in `bytes`, a PDF, read back. +fn pdf_contents(bytes: &[u8]) -> (usize, usize) { + // The page as drawn (not the VectorCraft document the PDF carries). + let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap(); + let (mut images, mut paths) = (0, 0); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images += 1, + NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1, + _ => {} + }); + (images, paths) +} + +/// The JSON of a `.vectorcraft` file (the data after it left out). +fn file_json(bytes: &[u8]) -> Value { + let end = bytes.windows(vectorcraft_format::BLOB_MAGIC.len()).position(|w| w == vectorcraft_format::BLOB_MAGIC).unwrap_or(bytes.len()); + serde_json::from_slice(&bytes[..end]).unwrap() +} + +#[test] +fn a_document_places_linked_by_default_at_its_artboard_size() { + let dir = Folder::new("placed-basic"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let p = placed(&s, id); + assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}"); + assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0)); + assert_eq!((p.link.path.as_str(), p.link.page), (src.as_str(), Some(1))); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert_eq!((n.name.as_deref(), n.kind_label()), (Some("logo.vectorcraft"), "Placed Document")); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // It moves and scales like any object. + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(bounds(&s, id), Rect::new(160.0, 125.0, 260.0, 175.0)); + // Its art's bounds instead of the artboard. + let mut d = Document::new(100.0, 50.0); + let layer = d.layers[0].id; + let id2 = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(id2, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 30.0)), look(RED))).unwrap(); + let small = dir.file("small.vectorcraft"); + write(&small, &vectorcraft_format::save_file(&d)); + let r = s.execute("file.place", &json!({"path": small, "crop": "bounding", "at": [200, 150]})).unwrap(); + let p = placed(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(p.bounding && (p.width - 30.0).abs() < 1e-6 && (p.height - 20.0).abs() < 1e-6, "{p:?}"); +} + +#[test] +fn without_link_or_a_file_a_document_places_as_an_editable_copy() { + let dir = Folder::new("placed-copy"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let data = vectorcraft_format::base64_encode(&std::fs::read(&src).unwrap()); + for p in [json!({"path": src, "link": false}), json!({"name": "logo.vectorcraft", "dataBase64": data})] { + let r = s.execute("file.place", &p).unwrap(); + assert_eq!(r["linked"], false, "{r}"); + let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(n.kind_label(), "Clip Group", "{p}"); + } + assert!(!s.doc().unwrap().doc.has_placed()); +} + +#[test] +fn a_placed_document_is_vectors_in_every_output() { + let dir = Folder::new("placed-outputs"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + place(&mut s, &src); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let text = svg["text"].as_str().unwrap(); + assert!(!text.contains(" 0); + let png = decode(&s.execute("document.serialize", &json!({"format": "png"})).unwrap()); + let [r, g, b, _] = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(200, 150).0; + assert!(near([r, g, b], RED), "{:?}", [r, g, b]); + for format in ["eps", "emf", "dxf"] { + let out = s.execute("document.serialize", &json!({"format": format})).unwrap(); + assert!(out.to_string().len() > 200, "{format}: {out}"); + } + let info = s.execute("document.info", &json!({})).unwrap(); + assert!(info.to_string().contains("\"placedDocuments\""), "{info}"); +} + +#[test] +fn a_document_saved_with_previews_outputs_its_files_art() { + let dir = Folder::new("placed-preview-output"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + open(&mut s, &doc_path); + let key = placed(&s, id).key; + assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // Output reads the file again: vectors, no warning. + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg["warnings"], json!([]), "{}", svg["warnings"]); + let text = svg["text"].as_str().unwrap(); + assert!(!text.contains(" Date: Wed, 7 Oct 2026 23:56:44 -0700 Subject: [PATCH 10/74] Placed documents: Links commands check, update, relink and break their links --- crates/engine/src/cmd/expand.rs | 32 +- crates/engine/src/cmd/fileio/save.rs | 5 + crates/engine/src/cmd/links.rs | 482 ++++++++++++++++----- crates/engine/src/cmd/place/document.rs | 31 +- crates/engine/src/tests_placed_document.rs | 234 +++++++++- 5 files changed, 659 insertions(+), 125 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index c8db5897..0e93921d 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1,6 +1,7 @@ //! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills -//! (Fill) into plain art in one undo step. A linear or radial gradient fill becomes a gradient mesh, -//! or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform +//! (Fill) into plain art in one undo step. Placed documents (Object) become editable copies of the +//! art they show, their resources joining the document. A linear or radial gradient fill becomes a +//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling. use std::sync::Arc; @@ -36,7 +37,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -55,6 +56,21 @@ pub fn specs() -> Vec { ] } +/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it +/// shows, keeping its id and place; the resources the art uses join `d` (a name `d` uses for +/// something else gets a free variant). +fn expand_placed_under(d: &mut Document, roots: &[NodeId]) -> Result<()> { + let mut ids = vec![]; + for n in roots.iter().filter_map(|r| d.node(*r)) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::PlacedDocument(_)) { + ids.push(c.id); + } + }); + } + ids.into_iter().try_for_each(|id| super::place::document::expand(d, id, C)) +} + /// Expand Gradient To. #[derive(Clone, Copy, Debug, PartialEq)] enum GradientTo { @@ -88,6 +104,8 @@ struct Expandable { envelope: bool, /// Live blends (Object expands them into groups of their steps). blends: bool, + /// Placed documents (Object expands them into the art they show). + placed: bool, } impl Expandable { @@ -101,6 +119,7 @@ impl Expandable { e.effects |= !c.appearance.effects.is_empty(); e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); e.blends |= matches!(c.kind, NodeKind::Blend { .. }); + e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -112,7 +131,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope || self.blends + self.text || self.live || self.effects || self.envelope || self.blends || self.placed } } @@ -135,6 +154,11 @@ fn expand(s: &mut Session, p: &Value) -> Result { let from = s.doc()?.history.undo.len(); let rev0 = s.doc()?.revision; let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Placed documents first: their art (type, strokes, gradients) expands below. + if object && e.placed { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| expand_placed_under(d, &roots))?; + } // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. if object && e.blends { let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 11ff3090..ed776be7 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -450,12 +450,17 @@ impl SaveJob { /// its own also ends its Data Recovery copy. pub fn complete(self, s: &mut Session, uid: u64) { let saved = self.plan.retargets() && self.plan.path.is_some(); + // The other open documents that place this one show the new version. + let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); if let Some(st) = s.document_mut(uid) { self.finish(st); } if saved { crate::cmd::recovery::forget(s, uid); } + if let Some(path) = written { + crate::cmd::links::refresh_placed(s, uid, &path); + } } } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e9260936..575a6f26 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -16,13 +16,17 @@ //! selects one (`links.goTo`), Embed keeps a linked file's pixels in the document //! (`links.embed`) and Unembed writes an embedded image to a file it then links to //! (`links.unembed`). +//! +//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their +//! files the same way: checking, updating, relinking and finding them on open work alike, and they +//! are listed. Embed (Break Link) turns one into an editable copy of its art. use std::collections::BTreeMap; use std::path::{Component, Path}; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacementOptions}; +use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -35,7 +39,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}", has_doc, check ), @@ -44,7 +48,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -53,7 +57,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names]}", has_doc, relink ), @@ -62,7 +66,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order, top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", has_doc, list ), @@ -71,7 +75,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image `id` (default: the first selected image); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", has_doc, go_to ), @@ -80,7 +84,7 @@ pub fn specs() -> Vec { "Embed Image", [], None, - "{ids?: [image ids] (default: the selected linked images)} keep the linked files' pixels in the document and drop the links (an image whose file can't be found and that shows the saved preview stays linked: relink it first). One undo step → {embedded: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}", has_doc, embed ), @@ -98,7 +102,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch)", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size", has_doc, info ), @@ -107,7 +111,7 @@ pub fn specs() -> Vec { "Placement Options", [], None, - "{ids?: [image ids] (default: the selected images), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}", + "{ids?: [image or placed document ids] (default: the selected ones), preserve?: transforms|bounds (default)|fileDimensions|fit|fill, align?: topLeft|top|topRight|left|center (default)|right|bottomLeft|bottom|bottomRight (where the new art sits; not for bounds), clip?: false (clip it to the old bounds where it is larger)} how a file read again (links.relink, links.update) takes each image's place: transforms keeps its scale (relative to each file's 100% size), rotation and position; bounds stretches it into the old bounds; fileDimensions puts it at 100%, unrotated; fit and fill scale it proportionally to fit inside or cover the old bounds. Without options → {placement} of the first image; else one undo step → {ids, placement}", has_doc, placement_options ), @@ -134,22 +138,34 @@ impl Status { } } -/// The image objects that show one linked file as one content (same path, same image). +/// The image objects that show one linked file as one content (same path, same image), or the +/// placed documents that show one artboard of it. struct Group { link: LinkInfo, key: String, /// The objects in the layers (images in symbols and patterns share the content, not an id). ids: Vec, + /// Placed documents: whether the art's bounds (not the artboard) are shown. + document: Option, } -/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then, -/// without `ids`, the ones only symbols and patterns show. +/// The linked object `n` as (link, content key, placed document's bounding). +fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { + match &n.kind { + NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)), + NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))), + _ => None, + } +} + +/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when +/// given), then, without `ids`, the ones only symbols and patterns show. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; - let mut index: BTreeMap<(String, String), usize> = BTreeMap::new(); - let mut add = |link: &LinkInfo, key: &str, id: Option| { - let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| { - out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] }); + let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); + let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { + let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { + out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); out.len() - 1 }); if let (Some(id), Some(g)) = (id, out.get_mut(i)) { @@ -157,19 +173,19 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { } }; d.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && let Some(l) = &im.link + if let Some((l, key, document)) = linked(n) && ids.is_none_or(|ids| ids.contains(&n.id)) { - add(l, &im.key, Some(n.id)); + add(l, key, document, Some(n.id)); } }); if ids.is_none() { d.visit_images(|_, im| { if let Some(l) = &im.link { - add(l, &im.key, None); + add(l, &im.key, None, None); } }); + d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None)); } out } @@ -238,7 +254,7 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option) { let same = match &l.hash { Some(h) => *h == hash_bytes(&bytes), // Linked before links kept a hash: the image the file decodes to. - None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), + None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), }; f.bytes = Some(bytes); (if same { Status::Ok } else { Status::Modified }, Some(f)) @@ -254,6 +270,15 @@ fn previewing(d: &Document, key: &str) -> bool { d.images.get(key).is_none_or(ImageBlob::is_proxy) } +/// Does group `g` of `d` show something other than its file: an image its preview, a placed +/// document nothing (its file's bytes are gone)? +fn stale(d: &Document, g: &Group) -> bool { + match g.document { + None => previewing(d, &g.key), + Some(_) => !d.images.contains_key(&g.key), + } +} + /// A linked file of a document (File → Package): its path, name and bytes. pub(crate) struct LinkedFile { pub path: String, @@ -301,6 +326,51 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result { Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?)) } +/// A linked file read for the objects that show it. +enum Read { + Image(FileRead), + /// A VectorCraft document read for placed documents: the link to it and what they show. + Document(LinkInfo, super::place::document::Source), +} + +/// The file at `f` read for group `g`: its image, or the artboard `g` shows. +fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { + let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) }; + let bytes = f.read()?; + let page = g.link.page.unwrap_or(1); + let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?; + Ok(Read::Document(f.link(&bytes, &g.link), src)) +} + +/// Objects `ids` show what was read (see [`install`], [`install_document`]). +fn install_read(d: &mut Document, ids: &[NodeId], read: Read, keep_bounds: bool) -> Result<()> { + match read { + Read::Image(r) => install(d, ids, r, keep_bounds), + Read::Document(link, src) => install_document(d, ids, &link, &src, keep_bounds), + } +} + +/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its +/// Placement Options say (`keep_bounds`: in its bounds, whatever they say). +fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, keep_bounds: bool) -> Result<()> { + let mut clips = vec![]; + let (w, h) = (src.frame.width(), src.frame.height()); + for id in ids { + let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue }; + let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..p.placement } } else { p.placement }; + let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o); + if let Some(n) = d.node_mut(*id) + && let NodeKind::PlacedDocument(p) = &mut n.kind + { + let p: &mut PlacedDocument = p; + (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); + } + clips.extend(clip.map(|c| (*id, c))); + } + super::place::document::store(d, src); + clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip)) +} + /// Points per pixel of the file image `key` of `d` shows, at the resolution it declares (72 ppi /// when it declares none, or when only its preview is loaded). fn file_pt(d: &Document, key: &str) -> (f64, f64) { @@ -318,9 +388,10 @@ type Clip = (Affine, (f64, f64)); /// The transform an image placed with `xf` showing `old` pixels (`old_pt`: points per pixel at /// 100%) gives a new file of `new` pixels (`new_pt`), as Placement Options `o` say; and the clip -/// when `o.clip` and the new art overflows the old bounds. -fn placed_xf(xf: Affine, old: (u32, u32), old_pt: (f64, f64), new: (u32, u32), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option) { - let (ow, oh, nw, nh) = (old.0 as f64, old.1 as f64, new.0.max(1) as f64, new.1.max(1) as f64); +/// when `o.clip` and the new art overflows the old bounds. (A placed document's "pixels" are points, +/// one point each.) +fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), new_pt: (f64, f64), o: PlacementOptions) -> (Affine, Option) { + let (ow, oh, nw, nh) = (old.0, old.1, new.0.max(1e-9), new.1.max(1e-9)); let [a, b, c, d, ..] = xf.as_coeffs(); // The bounds' frame: the transform without its scale (rotation, reflection and position). let (su, sv) = (a.hypot(b), c.hypot(d)); @@ -388,7 +459,8 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, keep_bounds: for id in ids { let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue }; let o = if keep_bounds { PlacementOptions { preserve: Preserve::Bounds, ..im.placement } } else { im.placement }; - let (xf, clip) = placed_xf(im.xf, (im.width, im.height), file_pt(d, &im.key), (img.width, img.height), new_pt, o); + let size = |w: u32, h: u32| (w as f64, h as f64); + let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o); if let Some(n) = d.node_mut(*id) && let NodeKind::Image(im) = &mut n.kind { @@ -436,11 +508,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv let row = rows(std::iter::once(&g)); match probe(&g, folder(doc_path)) { (Status::Missing, _) => out.missing.extend(row), - (Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link).and_then(|read| install(d, &g.ids, read, false)) { - Ok(()) => out.updated.extend(row), - Err(_) => out.missing.extend(row), - }, + (Status::Modified, Some(mut f)) if update => { + match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, false)) { + Ok(()) => out.updated.extend(row), + Err(_) => out.missing.extend(row), + } + } (Status::Modified, _) => out.modified.extend(row), + // A placed document keeps its file's bytes: only lost ones are read again. + (Status::Ok, Some(mut f)) if g.document.is_some() => { + let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; + match read { + Some(Ok(read)) => { + if install_read(d, &g.ids, read, true).is_err() { + out.missing.extend(row); + } + } + Some(Err(_)) => out.missing.extend(row), + None => { + let link = match f.bytes.take() { + Some(b) => f.link(&b, &g.link), + None => LinkInfo { path: f.path.clone(), ..g.link.clone() }, + }; + if link != g.link { + set_links(d, &g.ids, &link); + } + } + } + } (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); // Read when the images show the preview, or when the file's details changed. @@ -471,17 +566,58 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv out } -/// Image objects `ids` link to `link`. +/// The link of linked object `n` (a linked image or a placed document) to change. +fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { + match &mut n.kind { + NodeKind::Image(im) => im.link.as_mut(), + NodeKind::PlacedDocument(p) => Some(&mut p.link), + _ => None, + } +} + +/// Linked image objects (or placed documents) `ids` link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { for id in ids { - if let Some(n) = d.node_mut(*id) - && let NodeKind::Image(im) = &mut n.kind - { - im.link = Some(link.clone()); + if let Some(l) = d.node_mut(*id).and_then(link_mut) { + *l = link.clone(); } } } +/// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open +/// documents: read again from the new file, each as one Update Links step of its document. +pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { + let path = absolute_path(path); + let targets: Vec<(usize, Vec)> = s + .documents() + .iter() + .enumerate() + .filter(|(_, st)| st.uid != saved) + .filter_map(|(i, st)| { + let mut ids = vec![]; + st.doc.walk(|n| { + if let NodeKind::PlacedDocument(p) = &n.kind + && p.link.path == path + { + ids.push(n.id.0); + } + }); + (!ids.is_empty()).then_some((i, ids)) + }) + .collect(); + if targets.is_empty() { + return; + } + // Each document is updated as the active one, without switching tools. + let back = s.active; + for (i, ids) in targets { + s.active = Some(i); + // A file that can't be read leaves what its objects show: links.check reports it. + let _ = update(s, &json!({ "ids": ids })); + } + s.active = back; +} + // ---------- relative paths ---------- /// `target` relative to folder `base` with `/` separators; `None` when they share no root (another @@ -509,9 +645,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option { let dir = Path::new(&dest).parent()?; let mut changes = vec![]; d.walk(|n| { - if let NodeKind::Image(im) = &n.kind - && let Some(l) = &im.link - { + if let Some((l, _, _)) = linked(n) { let r = relative_path(Path::new(&l.path), dir); if r != l.relative { changes.push((n.id, r)); @@ -523,10 +657,7 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option { } let mut d = d.clone(); for (id, r) in changes { - if let Some(n) = d.node_mut(id) - && let NodeKind::Image(im) = &mut n.kind - && let Some(l) = &mut im.link - { + if let Some(l) = d.node_mut(id).and_then(link_mut) { l.relative = r; } } @@ -548,7 +679,7 @@ fn check(s: &mut Session, p: &Value) -> Result { if let Some(f) = found.filter(|f| f.path != g.link.path) { row["found"] = json!(f.path); } - if previewing(&st.doc, &g.key) { + if stale(&st.doc, g) { row["preview"] = json!(true); } links.push(row); @@ -564,9 +695,9 @@ fn update(s: &mut Session, p: &Value) -> Result { match probe(&g, folder(st.path.as_deref())) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), // Unchanged and showing the file: nothing to read unless asked for by id. - (Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {} - // Unreadable as an image counts as missing. - (_, Some(mut f)) => match read_image(&mut f, &g.link) { + (Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {} + // Unreadable as an image (or a document) counts as missing. + (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { Ok(read) => reads.push((g.ids, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, @@ -574,7 +705,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } let updated: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; } Ok(json!({ "updated": updated, "missing": missing })) } @@ -583,7 +714,14 @@ fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; let st = s.doc()?; let folder_param = str_param(p, "folder"); - let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None }); + // An image or a placed document, with its link. + let image = |id: &NodeId| { + st.doc.node(*id).and_then(|n| match &n.kind { + NodeKind::Image(im) => Some(im.link.as_ref()), + NodeKind::PlacedDocument(p) => Some(Some(&p.link)), + _ => None, + }) + }; let ids: Vec = match ids_param(p, "ids") { Some(ids) => ids, None => { @@ -599,7 +737,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } }; if let Some(id) = ids.iter().find(|id| image(id).is_none()) { - return Err(bad(C, format!("object {} is not an image", id.0))); + return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); @@ -611,7 +749,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { (Some(path), _) => files.push((ids, absolute_path(path))), (None, Some(dir)) => { for id in ids { - let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue }; + let Some(link) = image(&id).flatten() else { continue }; let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); match files.iter_mut().find(|(_, p)| *p == path) { Some((ids, _)) => ids.push(id), @@ -625,12 +763,34 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut reads = vec![]; for (ids, path) in files { let bytes = read_file(&path)?; - let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; - reads.push((ids, (link_info(&path, &bytes), img))); + let link = link_info(&path, &bytes); + // Images show the file; placed documents each show their artboard of it. + let mut images = vec![]; + let mut documents: Vec<((u32, bool), Vec)> = vec![]; + for id in ids { + match st.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::PlacedDocument(pl)) => { + let k = (pl.page(), pl.bounding); + match documents.iter_mut().find(|(e, _)| *e == k) { + Some((_, ids)) => ids.push(id), + None => documents.push((k, vec![id])), + } + } + _ => images.push(id), + } + } + if !images.is_empty() { + let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?; + reads.push((images, Read::Image((link.clone(), img)))); + } + for ((page, bounding), ids) in documents { + let src = super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?; + reads.push((ids, Read::Document(LinkInfo { page: Some(page), ..link.clone() }, src))); + } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install(d, &ids, read, false)))?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, false)))?; } not_found.sort(); not_found.dedup(); @@ -652,18 +812,6 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> st.selection.objects.iter().copied().filter(|id| image_of(&st.doc, *id).is_some_and(&keep)).collect() } -/// `ids` (else the selected images `keep` accepts), checked to be images; an error when none. -fn images_param(st: &crate::DocState, p: &Value, cmd: &str, keep: impl Fn(&ImageObject) -> bool) -> Result> { - let ids = ids_param(p, "ids").unwrap_or_else(|| selected_images(st, keep)); - if let Some(id) = ids.iter().find(|id| image_of(&st.doc, **id).is_none()) { - return Err(bad(cmd, format!("object {} is not an image", id.0))); - } - if ids.is_empty() { - return Err(bad(cmd, "select the images, or pass ids")); - } - Ok(ids) -} - /// `id`, else the first selected image. fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; @@ -674,6 +822,35 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { } } +/// The Placement Options of image or placed document `n`. +fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { + match &mut n.kind { + NodeKind::Image(im) => Some(&mut im.placement), + NodeKind::PlacedDocument(p) => Some(&mut p.placement), + _ => None, + } +} + +/// The Placement Options of image or placed document `id` of `d`. +fn placement_of(d: &Document, id: NodeId) -> Option { + match &d.node(id)?.kind { + NodeKind::Image(im) => Some(im.placement), + NodeKind::PlacedDocument(p) => Some(p.placement), + _ => None, + } +} + +/// `id`, else the first selected image or placed document. +fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { + let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some()); + let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + match placement_of(&st.doc, id) { + Some(_) => Ok(id), + None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), + } +} + /// Per linked image in the layers of `st`: its file's status, where it was found when away from /// its path, and whether the image shows its preview (each file probed once). fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { @@ -681,7 +858,7 @@ fn statuses(st: &crate::DocState) -> BTreeMap, b for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) { let (status, found) = probe(g, folder(st.path.as_deref())); let found = found.map(|f| f.path).filter(|p| *p != g.link.path); - let preview = previewing(&st.doc, &g.key); + let preview = stale(&st.doc, g); out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview)))); } out @@ -696,6 +873,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str { .map_or("Image", |f| f.label) } +/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size +/// (`pageWidth`, `pageHeight`, pt) instead of pixels. +fn document_row(p: &PlacedDocument) -> Value { + json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height }) +} + /// Sort rank of a `links.list` status: missing, modified, ok, embedded. fn status_rank(status: &str) -> usize { ["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4) @@ -709,17 +892,17 @@ fn list(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } let statuses = statuses(st); - let mut images = vec![]; + let mut objects = vec![]; st.doc.walk(|n| { - if let NodeKind::Image(im) = &n.kind { - images.push((n, im)); + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { + objects.push(n); } }); // Top first, as the Layers panel lists them. - images.reverse(); + objects.reverse(); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for (n, im) in images { + for n in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -728,17 +911,21 @@ fn list(s: &mut Session, p: &Value) -> Result { if show != "all" && show != status { continue; } - let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); - let mut row = json!({ - "id": n.id.0, - "name": name, - "linked": im.link.is_some(), - "status": status, - "format": format_label(&st.doc, im), - "pixelWidth": im.width, - "pixelHeight": im.height, - }); - if let Some(l) = &im.link { + let (link, mut row) = match &n.kind { + NodeKind::Image(im) => { + (im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height })) + } + NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)), + _ => continue, + }; + let name = link.map_or_else(|| n.display_name(), |l| l.name().to_string()); + if let Some(o) = row.as_object_mut() { + o.insert("id".into(), json!(n.id.0)); + o.insert("name".into(), json!(name)); + o.insert("linked".into(), json!(link.is_some())); + o.insert("status".into(), json!(status)); + } + if let Some(l) = link { row["path"] = json!(l.path); if let Some(page) = l.page { row["page"] = json!(page); @@ -766,7 +953,7 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let id = image_param(st, p, "links.goTo")?; + let id = linked_param(st, p, "links.goTo")?; let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); s.select(|_, sel| sel.set([id]))?; Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) @@ -775,9 +962,29 @@ fn go_to(s: &mut Session, p: &Value) -> Result { fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let ids = images_param(st, p, C, |im| im.link.is_some())?; + let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let linked = |id: &NodeId| match kind(id) { + Some(NodeKind::Image(im)) => im.link.is_some(), + Some(NodeKind::PlacedDocument(_)) => true, + _ => false, + }; + let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect()); + if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) { + return Err(bad(C, format!("object {} is not an image or a placed document", id.0))); + } + if ids.is_empty() { + return Err(bad(C, "select the linked images or placed documents, or pass ids")); + } + // Placed documents become editable copies of their art: those whose file can be read. + let (mut documents, mut missing) = (vec![], vec![]); + for id in ids.iter().copied() { + let Some(NodeKind::PlacedDocument(p)) = kind(&id) else { continue }; + let readable = st.doc.images.get(&p.key).is_some_and(|b| vectorcraft_doc::placed_document::full_bytes(p, b).is_some()); + if readable { documents.push(id) } else { missing.push(id) } + } + let ids: Vec = ids.into_iter().filter(|id| matches!(kind(id), Some(NodeKind::Image(_)))).collect(); // Images showing their preview take their file's pixels first. - let (mut done, mut missing, mut reads) = (vec![], vec![], vec![]); + let (mut done, mut reads) = (vec![], vec![]); for g in groups(&st.doc, Some(&ids)) { if !previewing(&st.doc, &g.key) { done.extend(g.ids); @@ -794,21 +1001,20 @@ fn embed(s: &mut Session, p: &Value) -> Result { _ => missing.extend(g.ids), } } - if !done.is_empty() { + if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { for (ids, read) in reads { install(d, &ids, read, true)?; } for id in &done { - if let Some(n) = d.node_mut(*id) - && let NodeKind::Image(im) = &mut n.kind - { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { im.link = None; } } - Ok(()) + documents.iter().try_for_each(|id| super::place::document::expand(d, *id, C)) })?; } + done.extend(documents); Ok(json!({ "embedded": ids_json(&done), "missing": ids_json(&missing) })) } @@ -862,41 +1068,77 @@ fn placement_json(o: PlacementOptions) -> Value { json!({ "preserve": o.preserve.id(), "align": o.align.id(), "clip": o.clip }) } +/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. +fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0); + let stamp = file_stamp(&l.path); + let folder_len = l.path.len().saturating_sub(l.name().len()); + out["status"] = json!(status.as_str()); + out["path"] = json!(l.path); + out["fileName"] = json!(l.name()); + out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\'])); + for (k, v) in [ + ("page", l.page.map(u64::from)), + ("fileSize", stamp.map(|s| s.0).or(l.size)), + ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("created", file_created(&l.path)), + ] { + if let Some(v) = v { + out[k] = json!(v); + } + } +} + +/// Scale (% of 100%, given points per unit at 100%) and counter-clockwise rotation of `xf`. +fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { + let [a, b, c, d, ..] = xf.as_coeffs(); + // Counter-clockwise on the page (y points down). Adding 0 turns -0 into 0. + (json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]), json!((-b).atan2(a).to_degrees() + 0.0)) +} + +/// Link Info of placed document `id`. +fn placed_info(st: &crate::DocState, id: NodeId) -> Result { + let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; + let size = n.geometric_bounds().unwrap_or_default(); + let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); + let mut out = document_row(pl); + let fields = [ + ("id", json!(id.0)), + ("name", json!(n.display_name())), + ("linked", json!(true)), + ("link", json!(pl.link.path)), + ("width", json!(size.width())), + ("height", json!(size.height())), + ("scale", scale), + ("rotation", rotation), + ("placement", placement_json(pl.placement)), + ]; + if let Some(o) = out.as_object_mut() { + o.extend(fields.into_iter().map(|(k, v)| (k.to_string(), v))); + } + file_fields(st, id, &pl.link, &mut out); + Ok(out) +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "links.info"; - let id = image_param(s.doc()?, p, C)?; + let id = linked_param(s.doc()?, p, C)?; + if image_of(&s.doc()?.doc, id).is_none() { + return placed_info(s.doc()?, id); + } let mut out = super::place::image_info(s, &json!({ "id": id.0 }))?; let st = s.doc()?; let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; let (px, py) = file_pt(&st.doc, &im.key); - let [a, b, c, d, ..] = im.xf.as_coeffs(); out["effectivePpi"] = out["ppi"].take(); out["ppi"] = json!([72.0 / px, 72.0 / py]); - out["scale"] = json!([a.hypot(b) / px * 100.0, c.hypot(d) / py * 100.0]); - // Counter-clockwise on the page (y points down). - // Adding 0 turns -0 into 0. - out["rotation"] = json!((-b).atan2(a).to_degrees() + 0.0); + (out["scale"], out["rotation"]) = scale_rotation(im.xf, (px, py)); out["format"] = json!(format_label(&st.doc, im)); out["placement"] = placement_json(im.placement); out["status"] = json!("embedded"); if let Some(l) = &im.link { - let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0); - let stamp = file_stamp(&l.path); - let folder_len = l.path.len().saturating_sub(l.name().len()); - out["status"] = json!(status.as_str()); - out["path"] = json!(l.path); - out["fileName"] = json!(l.name()); - out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\'])); - for (k, v) in [ - ("page", l.page.map(u64::from)), - ("fileSize", stamp.map(|s| s.0).or(l.size)), - ("modified", stamp.and_then(|s| s.1).or(l.modified)), - ("created", file_created(&l.path)), - ] { - if let Some(v) = v { - out[k] = json!(v); - } - } + file_fields(st, id, l, &mut out); } Ok(out) } @@ -904,7 +1146,13 @@ fn info(s: &mut Session, p: &Value) -> Result { fn placement_options(s: &mut Session, p: &Value) -> Result { const C: &str = "links.placementOptions"; let st = s.doc()?; - let ids = images_param(st, p, C, |_| true)?; + let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect()); + if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) { + return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); + } + if ids.is_empty() { + return Err(bad(C, "select the images, or pass ids")); + } let choice = |k: &str| -> Result> { match p.get(k) { None | Some(Value::Null) => Ok(None), @@ -918,7 +1166,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "clip must be true or false"))?), }; - let first = ids.first().and_then(|id| image_of(&st.doc, *id)).map(|im| im.placement).unwrap_or_default(); + let first = ids.first().and_then(|id| placement_of(&st.doc, *id)).unwrap_or_default(); if preserve.is_none() && align.is_none() && clip.is_none() { return Ok(json!({ "placement": placement_json(first) })); } @@ -929,10 +1177,8 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; s.edit("Placement Options", |d, _| { for id in &ids { - if let Some(n) = d.node_mut(*id) - && let NodeKind::Image(im) = &mut n.kind - { - set(&mut im.placement); + if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + set(o); } } Ok(()) diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 624259d0..a5184c1a 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -3,8 +3,9 @@ //! //! The object keeps the file's bytes (in the document's images, like image data) and the link; the //! art it shows is read from them the way placing the file without Link reads it -//! ([`document_art`]: the artboard's art, with the file's own linked images found). The Links -//! commands ([`super::super::links`]) tell when the file changed and read it again. +//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link +//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands +//! ([`super::super::links`]) tell when the file changed and read it again. //! //! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept //! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked @@ -244,3 +245,29 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { } (Cow::Owned(out), warnings) } + +/// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file +/// without Link gives (its resources joining `d`, renamed where `d` uses a name for something +/// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; + let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; + let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) + .ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?; + let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?; + let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?; + let mut nodes = a.nodes; + super::adopt::adopt(d, &a.doc, &mut nodes); + let mut children: Vec> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); + if let Some(r) = a.clip { + children.insert(0, Arc::new(super::clip_path(d.alloc_id(), r))); + } + let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); + let mut out = n.clone(); + out.kind = art.kind; + let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + d.remove(id)?; + d.insert(par, index, out)?; + Ok(()) +} diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs index da0f6b90..e1498c09 100644 --- a/crates/engine/src/tests_placed_document.rs +++ b/crates/engine/src/tests_placed_document.rs @@ -1,5 +1,6 @@ //! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read -//! from a file), drawn and output as vectors, and saved with its preview. +//! from a file), drawn and output as vectors, saved with its preview, checked, updated and +//! relinked when the file changes, and turned into an editable copy (Break Link, Expand). use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -158,6 +159,45 @@ fn a_placed_document_is_vectors_in_every_output() { assert!(info.to_string().contains("\"placedDocuments\""), "{info}"); } +#[test] +fn the_preview_is_saved_so_the_document_shows_it_without_its_file() { + let dir = Folder::new("placed-saved"); + let src = dir.file("logo.vectorcraft"); + // Content of its own: files read are cached by content, and this one is removed. + source(&src, 100.0, 50.0, RED, 2); + let mut s = session(); + let id = place(&mut s, &src); + let doc_path = dir.file("poster/poster.vectorcraft"); + std::fs::create_dir_all(dir.file("poster")).unwrap(); + save(&mut s, &doc_path); + let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + let kind = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]; + assert_eq!(kind["type"], "placeddocument"); + assert_eq!(kind["link"]["relative"], "../logo.vectorcraft"); + let key = kind["key"].as_str().unwrap(); + let entry = &saved["images"][key]; + assert_eq!((entry["proxy"].as_bool(), entry["mime"].as_str()), (Some(true), Some("image/jpeg")), "the preview, as JPEG (opaque)"); + // Include Linked Files keeps the file itself. + let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap())); + assert_eq!((full["images"][key]["proxy"].clone(), full["images"][key]["mime"].as_str()), (Value::Null, Some("application/json"))); + std::fs::remove_file(&src).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["missingLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert_eq!(placed(&s, id).link.path, src); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shown from the saved preview"); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 1); + // Output with the file gone: the preview, with a warning. + let png = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); + assert!(png["warnings"].to_string().contains("output as their previews"), "{png}"); + let [r, g, b, _] = image::load_from_memory(&decode(&png)).unwrap().to_rgba8().get_pixel(200, 150).0; + assert!(near([r, g, b], RED), "{:?}", [r, g, b]); + // Saved for older apps: the art it shows (here its preview), as plain objects. + let old: Value = + serde_json::from_slice(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap(); + let old = old["document"]["layers"][0]["kind"]["children"][0].to_string(); + assert!(old.contains("\"image\"") && !old.contains("placeddocument"), "{old}"); +} + #[test] fn a_document_saved_with_previews_outputs_its_files_art() { let dir = Folder::new("placed-preview-output"); @@ -182,6 +222,73 @@ fn a_document_saved_with_previews_outputs_its_files_art() { assert!(s.doc().unwrap().doc.images[&key].is_proxy()); } +#[test] +fn a_changed_file_is_modified_and_update_links_reads_it_again() { + let dir = Folder::new("placed-update"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let before = bounds(&s, id); + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((c["modified"].as_u64(), c["links"][0]["status"].as_str()), (Some(0), Some("ok")), "{c}"); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = &list["links"][0]; + assert_eq!((row["format"].as_str(), row["document"].as_bool(), row["status"].as_str()), (Some("VectorCraft"), Some(true), Some("ok")), "{row}"); + // A blue document, wider, and a different file size. + source(&src, 200.0, 50.0, BLUE, 1); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["links"][0]["status"], "modified"); + // Until updated, it shows (and outputs) the file as it read it. + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg["warnings"], json!([])); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"], json!([id.0]), "{u}"); + assert_eq!(placed(&s, id).width, 200.0); + assert_eq!(bounds(&s, id), before, "keeps its bounds"); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); +} + +#[test] +fn update_links_on_open_reads_a_modified_file_again() { + let dir = Folder::new("placed-open"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + place(&mut s, &src); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + source(&src, 100.0, 50.0, BLUE, 1); + // Ask When Modified (the default): reported, left as it was. + let r = open(&mut s, &doc_path); + assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // Automatically: read again as the document opens. + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["updatedLinks"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + +#[test] +fn relink_points_a_placed_document_at_another_file() { + let dir = Folder::new("placed-relink"); + let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft")); + source(&a, 100.0, 50.0, RED, 0); + source(&b, 100.0, 50.0, BLUE, 0); + let mut s = session(); + let id = place(&mut s, &a); + let r = s.execute("links.relink", &json!({"ids": [id.0], "path": b})).unwrap(); + assert_eq!(r["relinked"], json!([id.0]), "{r}"); + assert_eq!(placed(&s, id).link.path, b); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + #[test] fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() { let dir = Folder::new("placed-nested"); @@ -201,6 +308,28 @@ fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() { assert_eq!(pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap())).0, 0); } +#[test] +fn documents_that_place_each_other_dont_loop() { + let dir = Folder::new("placed-loop"); + let (a, b) = (dir.file("a.vectorcraft"), dir.file("b.vectorcraft")); + source(&a, 100.0, 50.0, RED, 0); + // B places A; A then places B. + let mut s = session(); + place(&mut s, &a); + save(&mut s, &b); + s.execute("document.open", &json!({"path": a})).unwrap(); + place(&mut s, &b); + save(&mut s, &a); + s.execute("document.open", &json!({"path": b})).unwrap(); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"].as_array().map(Vec::len), Some(1), "{u}"); + // Drawn and output a few levels deep, then their previews. + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + for format in ["svg", "pdf", "png"] { + s.execute("document.serialize", &json!({"format": format})).unwrap(); + } +} + #[test] fn what_cant_be_placed_linked_is_an_error() { let dir = Folder::new("placed-errors"); @@ -247,3 +376,106 @@ fn a_placed_documents_resources_stay_its_own() { assert!(!old.has_placed()); assert!(near(centre_colour(&old), BLUE)); } + +#[test] +fn break_link_gives_the_editable_copy_placing_without_link_gives() { + let dir = Folder::new("placed-break"); + let src = dir.file("badge.vectorcraft"); + star_source(&src, "break"); + let mut s = session(); + parent_star(&mut s); + // The copy placing without Link gives. + let r = s.execute("file.place", &json!({"path": src, "link": false, "at": [200, 150]})).unwrap(); + let copy = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let id = place(&mut s, &src); + let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["embedded"], json!([id.0]), "{r}"); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + assert!(!doc.has_placed() && n.kind_label() == "Clip Group", "{}", n.kind_label()); + assert_eq!((n.name.as_deref(), n.geometric_bounds()), (copy.name.as_deref(), copy.geometric_bounds())); + let kinds = |n: &Node| { + let mut k = vec![]; + n.walk(&mut |c| k.push(c.kind_label())); + k + }; + assert_eq!(kinds(n), kinds(©)); + assert!(near(centre_colour(doc), BLUE), "the art as it showed"); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names.len(), 2, "the file's Star joined, renamed: {names:?}"); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"], json!([])); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Embed"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.has_placed()); + // A file that can't be read stays linked. + std::fs::remove_file(&src).unwrap(); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + open(&mut s, &doc_path); + let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); + assert_eq!((r["embedded"].clone(), r["missing"].clone()), (json!([]), json!([id.0])), "{r}"); +} + +#[test] +fn expand_turns_a_placed_document_into_editable_art_with_its_resources() { + let dir = Folder::new("placed-expand"); + let src = dir.file("badge.vectorcraft"); + star_source(&src, "star"); + let mut s = session(); + parent_star(&mut s); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let before = bounds(&s, id); + s.execute("object.expand", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let n = doc.node(id).unwrap(); + assert!(n.is_container() && !doc.has_placed(), "{:?}", n.kind_label()); + let after = n.geometric_bounds().unwrap(); + assert!((after.width() - before.width()).abs() < 0.5 && (after.height() - before.height()).abs() < 0.5, "{after:?} vs {before:?}"); + assert!(near(centre_colour(doc), BLUE)); + let saved = vectorcraft_format::save_file(doc); + assert!(near(centre_colour(&vectorcraft_format::load(&saved).unwrap()), BLUE), "saved with its resources"); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!(info["object"], false, "nothing left to expand: {info}"); +} + +#[test] +fn saving_a_document_refreshes_the_open_documents_that_place_it() { + let dir = Folder::new("placed-refresh"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let parent = s.active_index().unwrap(); + // Open the file, recolour it and save it. + s.execute("document.open", &json!({"path": src})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let child = s.active_index().unwrap(); + assert_ne!(parent, child); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + assert!(near(centre_colour(&doc.doc), BLUE), "shows the saved version"); + assert!(matches!(doc.doc.node(id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))); + assert_eq!(s.active_index(), Some(child), "the saved document stays active"); +} + +#[test] +fn link_info_gives_the_artboards_size_not_pixels() { + let dir = Folder::new("placed-info"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); + assert_eq!((i["pageWidth"].as_f64(), i["pageHeight"].as_f64(), i["document"].as_bool()), (Some(100.0), Some(50.0), Some(true)), "{i}"); + assert!(i.get("pixelWidth").is_none() && i.get("ppi").is_none(), "no pixels: {i}"); + assert_eq!( + (i["status"].as_str(), i["fileName"].as_str(), i["scale"].clone()), + (Some("ok"), Some("logo.vectorcraft"), json!([200.0, 200.0])), + "{i}" + ); +} From 24189217609d51af9bbe9c0fb229c84d988bd4d5 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:04 -0700 Subject: [PATCH 11/74] Placed documents: on screen, cached bitmaps made in the background --- crates/render/src/lib.rs | 4 + crates/render/src/paint.rs | 17 +- crates/render/src/placed_document.rs | 295 ++++++++++++++++++++- crates/render/src/tests_placed_document.rs | 67 ++++- crates/ui-egui/src/canvas.rs | 13 + crates/ui-egui/src/lib.rs | 2 + 6 files changed, 378 insertions(+), 20 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a6e43538..42d68dc0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -97,6 +97,9 @@ pub struct RenderOptions { pub highlight_substitutions: bool, /// Edge smoothing (raster export option). pub anti_alias: AntiAlias, + /// Screen view: placed documents draw from cached bitmaps made in the background (see + /// [`placed_document`]); off, they draw exactly, read when needed. + pub progressive_placed: bool, } /// How edges are rasterized (raster export option). @@ -161,6 +164,7 @@ impl Default for RenderOptions { mask_view: None, highlight_substitutions: false, anti_alias: AntiAlias::Art, + progressive_placed: false, } } } diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e99adfae..0f6e41bb 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -52,7 +52,22 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr /// Decode encoded image bytes into a premultiplied pixmap. pub fn decode_pixmap(bytes: &[u8]) -> Option { - let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8()) +} + +/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged). +pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option { + let img = image::load_from_memory(bytes).ok()?; + let img = if img.width() > w as u32 || img.height() > h as u32 { + img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + } else { + img + }; + pixmap_of(img.to_rgba8()) +} + +/// An RGBA image as a premultiplied pixmap. +fn pixmap_of(img: image::RgbaImage) -> Option { let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 8955b6b0..65e886f5 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -1,22 +1,138 @@ -//! Drawing placed documents ([`PlacedDocument`]): the art read from the file -//! ([`vectorcraft_doc::placed_document::read`]) drawn with its own resources, through a view that -//! maps its artboard into the object's box (nothing is copied into the document). +//! Drawing placed documents ([`PlacedDocument`]). //! -//! A document opened with only its placed documents' previews -//! ([`vectorcraft_doc::ImageBlob::is_proxy`]) shows them until the file is at hand -//! ([`vectorcraft_doc::placed_document::full_bytes_cached`]), and for good when it can't be found. Placed documents inside a placed document are read from their -//! files, up to [`MAX_DEPTH`] deep; deeper ones show their previews. +//! Exactly: the art read from the file ([`vectorcraft_doc::placed_document::read`]) drawn with its +//! own resources, through a view that maps its artboard into the object's box (nothing is copied +//! into the document). +//! +//! On screen ([`RenderOptions::progressive_placed`]): a placed document can't be edited, so what +//! it shows is cached as a bitmap per file and zoom step (a power of two, in device pixels per +//! point), shared by every object showing that file. A bitmap not made yet is made on a +//! background worker while the frame shows another step's bitmap, or a light placeholder; +//! [`generation`] changes when one is ready and [`busy`] says whether any is coming, so the screen +//! can draw again. Past [`MAX_SIDE`] pixels, and in outline, proofing and ink frames, it is drawn +//! exactly. +//! +//! A document opened with only its placed documents' previews ([`ImageBlob::is_proxy`]) shows them +//! up to their resolution; past it the file is read again in the background +//! ([`vectorcraft_doc::placed_document::full_bytes`]), the preview standing in until then (and for +//! good when the file can't be found). Placed documents inside a placed document are drawn the +//! same way, up to [`MAX_DEPTH`] deep; deeper ones show their previews. use std::cell::Cell; -use std::sync::Arc; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}; use vectorcraft_doc::placed_document::MAX_DEPTH; -use vectorcraft_doc::{Node, PlacedDocument}; -use vectorcraft_geom::Affine; -use vello_cpu::RenderContext; +use vectorcraft_doc::{ImageBlob, Node, PlacedDocument}; +use vectorcraft_geom::{Affine, Rect}; +use vello_cpu::{Pixmap, RenderContext, kurbo, peniko}; +use crate::ink::Ink; use crate::{Frame, Renderer}; +/// The longest side of a cached bitmap (pixels); larger steps draw exactly. +pub const MAX_SIDE: f64 = 4096.0; +/// The most bytes of bitmaps kept. +const BUDGET: usize = 512 << 20; +/// The smallest zoom step (device pixels per point). +const MIN_STEP: f64 = 1.0 / 64.0; + +/// A bitmap's identity: the file shown (its key) and the zoom step's bits. +type BitmapKey = (String, u64); + +struct Bitmap { + pm: Arc, + step: f64, + used: u64, +} + +#[derive(Default)] +struct Bitmaps { + map: HashMap, + clock: u64, + bytes: usize, +} + +static BITMAPS: LazyLock> = LazyLock::new(Default::default); +/// Bitmaps being made. +static PENDING: LazyLock>> = LazyLock::new(Default::default); +static GENERATION: AtomicU64 = AtomicU64::new(0); + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + // The caches stay whole if a holder panicked (entries are only inserted and removed). + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Changes whenever a bitmap made in the background is ready: a screen showing placed documents +/// draws again when it does. +pub fn generation() -> u64 { + GENERATION.load(Ordering::Relaxed) +} + +/// Are bitmaps being made in the background? +pub fn busy() -> bool { + !lock(&PENDING).is_empty() +} + +/// Is a bitmap of file `key` being made? +#[cfg(test)] +pub(crate) fn pending_for(key: &str) -> bool { + lock(&PENDING).iter().any(|k| k.0 == key) +} + +/// The zoom step for `scale` device pixels per point: the power of two at or above it. +pub(crate) fn step(scale: f64) -> Option { + (scale.is_finite() && scale > 0.0).then(|| 2f64.powf(scale.log2().ceil()).max(MIN_STEP)) +} + +impl Bitmaps { + fn get(&mut self, k: &BitmapKey) -> Option> { + self.clock += 1; + let clock = self.clock; + let b = self.map.get_mut(k)?; + b.used = clock; + Some(b.pm.clone()) + } + + /// The bitmap of another step of the same file, the nearest to `step`. + fn nearest(&self, key: &str, step: f64) -> Option<(Arc, f64)> { + self.map + .iter() + .filter(|((k, _), _)| k == key) + .min_by(|a, b| (a.1.step / step).log2().abs().total_cmp(&(b.1.step / step).log2().abs())) + .map(|(_, b)| (b.pm.clone(), b.step)) + } + + fn insert(&mut self, k: BitmapKey, pm: Pixmap, step: f64) { + self.clock += 1; + let size = pm.width() as usize * pm.height() as usize * 4; + self.bytes += size; + if let Some(old) = self.map.insert(k, Bitmap { pm: Arc::new(pm), step, used: self.clock }) { + self.bytes = self.bytes.saturating_sub(old.pm.width() as usize * old.pm.height() as usize * 4); + } + while self.bytes > BUDGET && self.map.len() > 1 { + let Some(k) = self.map.iter().min_by_key(|(_, b)| b.used).map(|(k, _)| k.clone()) else { break }; + if let Some(b) = self.map.remove(&k) { + self.bytes = self.bytes.saturating_sub(b.pm.width() as usize * b.pm.height() as usize * 4); + } + } + } +} + +/// What a bitmap is made from. +struct Job { + key: BitmapKey, + object: PlacedDocument, + blob: ImageBlob, + step: f64, +} + +/// Device pixels per point of `object`'s box its preview `bytes` have. +fn preview_density(object: &PlacedDocument, bytes: &[u8]) -> f64 { + vectorcraft_doc::placed_document::image_size(bytes).map_or(0.0, |(w, _)| w as f64 / object.width.max(1e-9)) +} + thread_local! { /// How deep inside placed documents this thread is drawing. static DEPTH: Cell = const { Cell::new(0) }; @@ -49,14 +165,108 @@ pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Ar r.render_node(doc, art, w, h, view) } +/// `object`'s file (`blob`) as a bitmap of `step` device pixels per point of its box: from its +/// preview when that is all there is and is fine enough, else from the file (read again when +/// needed; the preview when it can't be). +fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64) -> Option { + let (w, h) = (pixels(object.width * step), pixels(object.height * step)); + let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; + let full = match blob.is_proxy() { + true if step <= preview_density(object, &blob.bytes) * 1.01 => None, + true => vectorcraft_doc::placed_document::full_bytes(object, blob), + false => Some(blob.bytes.clone()), + }; + let read = full.and_then(|bytes| vectorcraft_doc::placed_document::read(object, &bytes)); + let Some(e) = read else { return crate::paint::decode_pixmap_fit(&preview?, w, h) }; + let f = e.frame; + let (sx, sy) = (w as f64 / f.width(), h as f64 / f.height()); + let view = Affine::scale_non_uniform(sx, sy) * Affine::translate((-f.x0, -f.y0)); + let img = render_inside(r, &e.doc, &e.art, w, h, view)?; + let data = img.pixels.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| vello_cpu::color::PremulRgba8 { r, g, b, a }).collect(); + Some(Pixmap::from_parts(data, w, h)) +} + +/// A bitmap side for `v` pixels (at least 1). +fn pixels(v: f64) -> u16 { + v.round().clamp(1.0, MAX_SIDE) as u16 +} + +/// Make `job`'s bitmap and keep it. +fn run(r: &mut Renderer, job: Job) { + if let Some(pm) = make(r, &job.object, &job.blob, job.step) { + lock(&BITMAPS).insert(job.key.clone(), pm, job.step); + } + lock(&PENDING).remove(&job.key); + GENERATION.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(not(target_arch = "wasm32"))] +mod workers { + use std::sync::mpsc::{Sender, channel}; + use std::sync::{Arc, LazyLock, Mutex}; + + use super::Job; + + /// The queue the workers take jobs from; `None` when no worker could start. + static QUEUE: LazyLock>>>> = LazyLock::new(|| { + let (tx, rx) = channel::>(); + let rx = Arc::new(Mutex::new(rx)); + let n = std::thread::available_parallelism().map_or(2, |n| (n.get() / 2).clamp(1, 4)); + let mut started = 0; + for i in 0..n { + let rx = rx.clone(); + let spawned = std::thread::Builder::new().name(format!("vectorcraft-placed-{i}")).spawn(move || { + let mut r = crate::Renderer::new(); + r.threads = 0; + loop { + let job = super::lock(&rx).recv(); + let Ok(job) = job else { break }; + // A panic loses this bitmap, not the worker. + let key = job.key.clone(); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::run(&mut r, *job))).is_err() { + super::lock(&super::PENDING).remove(&key); + } + } + }); + started += usize::from(spawned.is_ok()); + } + (started > 0).then(|| Mutex::new(tx)) + }); + + /// Hand `job` to a worker; back when there is none. + pub(super) fn submit(job: Box) -> Result<(), Box> { + match QUEUE.as_ref() { + Some(q) => super::lock(q).send(job).map_err(|e| e.0), + None => Err(job), + } + } +} + +/// Make `job`'s bitmap in the background (here and now where there are no threads). +fn schedule(job: Job) { + if !lock(&PENDING).insert(job.key.clone()) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + let job = match workers::submit(Box::new(job)) { + Ok(()) => return, + Err(job) => *job, + }; + run(&mut Renderer::new(), job); +} + /// The mean scale of `a`. fn mean_scale(a: Affine) -> f64 { a.determinant().abs().sqrt() } impl Renderer { - /// Draw placed document `p` in frame `f`. + /// Draw placed document `p` in frame `f`: from a cached bitmap on screen, else exactly. pub(crate) fn draw_placed(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) { + let plain = f.ink == Ink::Display && !f.opts.outline && f.opts.proof.is_none() && !f.opts.overprint_preview; + if f.opts.progressive_placed && plain && depth() == 0 && self.draw_placed_bitmap(ctx, f, p) { + return; + } let Some(blob) = f.doc.images.get(&p.key) else { return }; // The file's bytes: stored, read again already, or (inside another placed document, while // not too deep) read again now. @@ -85,4 +295,65 @@ impl Renderer { let _inside = Inside::enter(); self.draw_arc(ctx, &inner, &e.art); } + + /// Draw `p` from its bitmap at this zoom step, or (while that is made) another step's, or a + /// placeholder. False when bitmaps don't suit (too large). + fn draw_placed_bitmap(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) -> bool { + let m = f.view * p.xf; + let Some(step) = step(mean_scale(m)) else { return false }; + if p.width * step > MAX_SIDE || p.height * step > MAX_SIDE { + return false; + } + let Some(blob) = f.doc.images.get(&p.key).cloned() else { return true }; + let key = (p.key.clone(), step.to_bits()); + let (found, nearest) = { + let mut b = lock(&BITMAPS); + let found = b.get(&key); + let nearest = if found.is_none() { b.nearest(&p.key, step) } else { None }; + (found, nearest) + }; + let shown = match found { + Some(pm) => Some(pm), + None => { + schedule(Job { key, object: p.clone(), blob, step }); + // Made here and now (no threads) or meanwhile another step's. + let mut b = lock(&BITMAPS); + b.get(&(p.key.clone(), step.to_bits())).or(nearest.map(|n| n.0)) + } + }; + let rect = Rect::new(0.0, 0.0, p.width, p.height); + match shown { + Some(pm) => { + let (sx, sy) = (p.width / pm.width().max(1) as f64, p.height / pm.height().max(1) as f64); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); + ctx.set_transform(m); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&kurbo::Rect::new(rect.x0, rect.y0, rect.x1, rect.y1)); + ctx.reset_paint_transform(); + } + None => { + // Coming: a light box where it will be. + ctx.set_transform(m); + ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 40)); + ctx.fill_rect(&kurbo::Rect::new(rect.x0, rect.y0, rect.x1, rect.y1)); + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zoom_steps_are_powers_of_two_at_or_above_the_scale() { + assert_eq!(step(1.0), Some(1.0)); + assert_eq!(step(1.2), Some(2.0)); + assert_eq!(step(0.3), Some(0.5)); + assert_eq!(step(5.0), Some(8.0)); + assert_eq!(step(1e-9), Some(MIN_STEP)); + assert_eq!(step(f64::NAN), None); + assert_eq!(step(0.0), None); + } } diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs index 0224fa39..c04af829 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests_placed_document.rs @@ -1,5 +1,5 @@ -//! Placed documents drawn exactly: with their own art, as their previews when that is all there -//! is, and a few levels deep when they place themselves. +//! Placed documents on screen: drawn from bitmaps made in the background (a placeholder until +//! then), the same as drawn exactly; exports draw them exactly at once. use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument}; @@ -45,19 +45,63 @@ fn doc(key: &str) -> Document { doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes()) } -fn render(d: &Document) -> Rendered { - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; +fn render(d: &Document, progressive: bool) -> Rendered { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), progressive_placed: progressive, ..Default::default() }; Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts) } +/// Wait for the background bitmaps (at most 10 s). +fn settle() { + let t = std::time::Instant::now(); + while placed_document::busy() && t.elapsed().as_secs() < 10 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!placed_document::busy(), "bitmaps still coming"); +} + #[test] fn a_placed_document_draws_exactly_with_its_own_art() { let d = doc("exact-1"); - let r = render(&d); + let r = render(&d, false); assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box"); assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside"); } +#[test] +fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() { + let d = doc("progressive-1"); + let generation = placed_document::generation(); + let first = render(&d, true); + let shown = &first.pixel(50, 30)[..3]; + assert!(shown == [0, 0, 255] || shown != [255, 255, 255], "a placeholder or the art: {shown:?}"); + settle(); + assert!(placed_document::generation() > generation || shown == [0, 0, 255], "a bitmap was made"); + let later = render(&d, true); + let exact = render(&d, false); + assert_eq!(&later.pixel(50, 30)[..3], &[0, 0, 255]); + for (x, y) in [(12, 12), (50, 30), (88, 48), (95, 95), (5, 5)] { + let (a, b) = (later.pixel(x, y), exact.pixel(x, y)); + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 24), "at ({x}, {y}): {a:?} vs {b:?}"); + } +} + +#[test] +fn copies_of_one_placed_document_share_its_bitmap() { + let mut d = doc("shared-1"); + let n = d.node(NodeId(50)).unwrap().clone(); + let l = d.layers[0].id; + let mut copy = n.clone(); + copy.id = NodeId(51); + copy.transform(Affine::translate((0.0, 45.0)), false); + d.insert(Some(l), 1, copy).unwrap(); + render(&d, true); + settle(); + let r = render(&d, true); + assert!(!placed_document::pending_for("shared-1"), "nothing more to make"); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); + assert_eq!(&r.pixel(50, 75)[..3], &[0, 0, 255]); +} + #[test] fn a_placed_document_kept_as_its_preview_draws_the_preview() { let mut d = doc("preview-1"); @@ -68,16 +112,25 @@ fn a_placed_document_kept_as_its_preview_draws_the_preview() { let mut blob = ImageBlob::new("image/png", png); blob.proxy = Some(blob.bytes.clone()); d.images.insert("preview-1".into(), blob); - let exact = render(&d); + let exact = render(&d, false); assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box"); assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]); + // On screen past the preview's resolution (it has 0.5 px a point; this is 2): still the preview. + render(&d, true); + settle(); + let shown = render(&d, true); + assert_eq!(&shown.pixel(50, 30)[..3], &[0, 200, 0]); } #[test] fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() { let d = doc_with("loop-1", b"loop"); let t = std::time::Instant::now(); - let r = render(&d); + let r = render(&d, false); assert!(t.elapsed().as_secs() < 10); assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); + // On screen too. + render(&d, true); + settle(); + assert_eq!(&render(&d, true).pixel(50, 30)[..3], &[0, 0, 255]); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4937c4c9..588924fa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -230,7 +230,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { hidden: vec![], rot: v.rotation, anti_alias: app.session.prefs.anti_aliased_artwork, + placed: vectorcraft_render::placed_document::generation(), }; + // Placed documents' bitmaps are being made: draw again when they are ready. + if vectorcraft_render::placed_document::busy() { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } if !app.canvas.worker_started { app.canvas.worker_started = true; if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() { @@ -265,6 +270,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and // pattern tiles stay smooth), as in Illustrator. anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, + progressive_placed: true, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. @@ -1030,6 +1036,13 @@ fn node_outline(n: &Node) -> BezPath { bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); } } + // A placed document shows its box, turned with it. + NodeKind::PlacedDocument(p) => { + let [a, b, c, d] = p.corners(); + bp.move_to(a); + [b, c, d].into_iter().for_each(|q| bp.line_to(q)); + bp.close_path(); + } // An envelope shows its mesh (or top object), not its content. NodeKind::Envelope { .. } => { if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eea9cbec..bf3ecb07 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -277,6 +277,8 @@ pub struct CacheKey { pub rot: f64, /// General › Anti-aliased Artwork. pub anti_alias: bool, + /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. + pub placed: u64, } #[derive(Clone, Copy, Debug, Default)] From 5e7af3f83cd7c54469f4837827b7b90ea15b9abe Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:11 -0700 Subject: [PATCH 12/74] Placed documents: Place's Link tooltip, Break Link in the Links panel --- ROADMAP.md | 5 +-- crates/ui-egui/src/dialogs/place.rs | 30 +++++++++++++++--- crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/it.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 +++ crates/ui-egui/src/menus.rs | 13 ++++++-- crates/ui-egui/src/panels/links.rs | 47 ++++++++++++++++++++++++----- crates/ui-egui/src/place.rs | 9 ++++++ crates/ui-egui/src/tests_place.rs | 37 +++++++++++++++++++++++ docs/mcp.md | 15 +++++++-- 13 files changed, 168 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ffeada5b..a0f3eddd 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 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -158,6 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - `.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); 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). - 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). + - 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. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland. @@ -221,7 +222,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 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% | 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 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 | -| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | +| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); 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) | 5–8 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | | Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | diff --git a/crates/ui-egui/src/dialogs/place.rs b/crates/ui-egui/src/dialogs/place.rs index 3ddd7171..90e234b6 100644 --- a/crates/ui-egui/src/dialogs/place.rs +++ b/crates/ui-egui/src/dialogs/place.rs @@ -3,7 +3,9 @@ //! object) or loads the place cursor with several ([`crate::place::confirm`]). //! //! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file -//! and one selected object: Replace applies), `__info` (a line per file). +//! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents +//! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per +//! file). use serde_json::{Value, json}; @@ -72,12 +74,32 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (key, label, tip) in OPTIONS { let on = d.bool(key); let enabled = key != "replace" || d.bool("__replace"); - let resp = ui.scope(|ui| widgets::check(ui, tl!(label), on && enabled, enabled)); - if resp.inner { + let tip = if key == "link" { link_tip(d) } else { tl!(tip).to_string() }; + if widgets::check_tip(ui, tl!(label), on && enabled, enabled, &tip) { d.fields.insert(key.into(), json!(!on)); } - resp.response.on_hover_text(tl!(tip)); } }); false } + +/// Link's tooltip: what it does for the files picked (images, VectorCraft documents, or both), a +/// line each. +pub(crate) fn link_tip(d: &Dialog) -> String { + let lines = match (d.bool("__documents"), d.bool("__others")) { + (true, false) => { + vec![ + tl!("On: one locked object showing the document, updated when it changes."), + tl!("Off: an editable copy of its art, embedded in this document."), + ] + } + (true, true) => { + vec![ + tl!("Images: link to the file instead of only embedding it."), + tl!("VectorCraft documents: one locked object, updated when the file changes."), + ] + } + _ => vec![tl!("Keep a link to an image file instead of only embedding it")], + }; + lines.join("\n") +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a2fc2160..e4ceed41 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3593,3 +3593,8 @@ axis Both Ambos Zoom Zoom Bring All to Front Traer todo al frente Settings Ajustes + Break Link Romper vínculo + On: one locked object showing the document, updated when it changes. Activado: un objeto bloqueado que muestra el documento y se actualiza cuando este cambia. + Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento. + Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo. + VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8ea33549..decd91fd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3593,3 +3593,8 @@ axis Both Entrambi Zoom Zoom Bring All to Front Porta tutto in primo piano Settings Impostazioni + Break Link Interrompi collegamento + On: one locked object showing the document, updated when it changes. Attivato: un unico oggetto bloccato che mostra il documento, aggiornato quando cambia. + Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento. + Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto. + VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1acb91c0..b479ea62 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3055,3 +3055,8 @@ axis Both 両方 Bring All to Front すべてを手前に移動 Settings 環境設定 theme Appearance 外観 + Break Link リンクを解除 + On: one locked object showing the document, updated when it changes. オン:ドキュメントを表示するロックされた 1 つのオブジェクト。ドキュメントの変更時に更新されます。 + 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 ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e28a7e86..9500ba89 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2643,3 +2643,8 @@ Zoom Zoom Bring All to Front Trazer Todas para a Frente Settings Configurações + Break Link Quebrar Vínculo + On: one locked object showing the document, updated when it changes. Ativado: um objeto bloqueado que mostra o documento, atualizado quando ele muda. + Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento. + Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo. + VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ec6f085f..ddc8134f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3310,3 +3310,8 @@ axis Both 两者 Hide VectorCraft 隐藏 VectorCraft Bring All to Front 前置全部窗口 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. 关闭:将其图稿的可编辑副本嵌入此文档。 + Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。 + VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 737cb4e9..a9d704ba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3085,3 +3085,8 @@ axis Both 兩者 Zoom 縮放 Bring All to Front 將全部移至最前 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. 關閉:將其圖稿的可編輯副本內嵌在此文件中。 + Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。 + VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a2576cb4..3ee6083d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -560,7 +560,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "links.editOriginal", "Edit Original", "", - "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original) → {path}; edits saved there show after links.update", + "{id?} open the file of linked image `id` (default: the selected linked image) in the system's default app for its type (Links panel, Edit › Edit Original); a placed document's file opens here in a new tab, and saving it updates the documents placing it → {path}; edits saved elsewhere show after links.update", ), ( "links.reveal", @@ -1837,8 +1837,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.spotColors" => app.session.active().is_some(), "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.dxfOptionsDialog" => app.session.active().is_some(), - "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()), - "ui.placementOptionsDialog" => selected_image(app, |_| true), + "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), + "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), @@ -1855,6 +1855,13 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { } } +/// Is a placed document selected? +fn selected_placed(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::PlacedDocument(_)))) + }) +} + /// Is an image `keep` accepts selected? fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObject) -> bool) -> bool { app.session.active().is_some_and(|st| { diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 124b215f..fef4221f 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -83,10 +83,12 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option< Some(c) } -/// The ids of the selected image objects. +/// The ids of the selected image objects and placed documents. fn selected_images(app: &VectorcraftApp) -> Vec { let Some(st) = app.session.active() else { return vec![] }; - let image = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + let image = |id: &NodeId| { + st.doc.node(*id).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_) | vectorcraft_doc::NodeKind::PlacedDocument(_))) + }; st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() } @@ -170,6 +172,11 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re if info["status"] == "missing" { return Err(format!("{path} can't be found: relink it first")); } + // 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 }))?; + return Ok(json!({ "path": path })); + } if reveal { crate::io::reveal_path(app, &path) } else { crate::io::open_in_app(app, &path) }?; Ok(json!({ "path": path })) } @@ -293,11 +300,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => {} } if o.info { - link_info(ui, cache.info.as_ref().map(|(_, i)| i)); + let units = app.session.general_unit(); + link_info(ui, cache.info.as_ref().map(|(_, i)| i), units); } let sel = selected_rows(&cache.list, &selected); let modified = ids(&sel, |r| status_is(r, "modified") || r["preview"] == true && !status_is(r, "missing")); let can_edit = sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")); + let linked = ids(&sel, |r| r["linked"] == true); widgets::bottom_bar(ui, |ui| { let info = !o.info; if widgets::icon_button(ui, if o.info { "chevron-down" } else { "chevron-right" }, tl!("Show Link Info"), false, 24.0).clicked() { @@ -319,10 +328,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() { relink(app, ids(&sel, |_| true)); } + if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() { + break_link(app, linked.clone()); + } }); }); } +/// Break Link (`links.embed`) of `ids`: images keep their files' pixels, placed documents become +/// editable copies of their art. +fn break_link(app: &mut VectorcraftApp, ids: Vec) { + let r = app.run("links.embed", json!({ "ids": ids })); + report(app, r, |v| { + let missing = v["missing"].as_array().map_or(0, Vec::len); + let n = v["embedded"].as_array().map_or(0, Vec::len); + if missing == 0 { format!("Broke {n} link(s)") } else { format!("Broke {n} link(s); {missing} can't be read: relink or update them first") } + }); +} + /// Pick-a-file actions need a file dialog (the desktop app). fn can_pick(app: &VectorcraftApp) -> bool { app.services.pick_open.is_some() @@ -344,7 +367,7 @@ pub(crate) fn date_label(ms: u64) -> String { } /// The Link Info section: the details of `info` (`links.info`), or a hint. -fn link_info(ui: &mut Ui, info: Option<&Value>) { +fn link_info(ui: &mut Ui, info: Option<&Value>, units: vectorcraft_doc::Unit) { widgets::divider(ui); let Some(i) = info else { widgets::dim_label(ui, tl!("Select an image to see its Link Info.")); @@ -365,9 +388,19 @@ fn link_info(ui: &mut Ui, info: Option<&Value>) { if let Some(l) = i["location"].as_str() { row(ui, tl!("Location"), l.into()); } - row(ui, tl!("PPI"), pair("ppi")); - row(ui, tl!("Effective PPI"), pair("effectivePpi")); - row(ui, tl!("Dimensions"), format!("{} × {} px", i["pixelWidth"], i["pixelHeight"])); + // A placed document has no resolution: its rows are left out. + for (label, k) in [(tl!("PPI"), "ppi"), (tl!("Effective PPI"), "effectivePpi")] { + if i.get(k).is_some() { + row(ui, label, pair(k)); + } + } + // Pixels, or a placed document's artboard size. + let dims = match (i["pixelWidth"].as_u64(), i["pageWidth"].as_f64(), i["pageHeight"].as_f64()) { + (Some(w), _, _) => format!("{w} × {} px", i["pixelHeight"].as_u64().unwrap_or(0)), + (None, Some(w), Some(h)) => format!("{} × {}", units.format(w), units.format(h)), + _ => String::new(), + }; + row(ui, tl!("Dimensions"), dims); let scale: Vec = i["scale"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); if let [x, y] = scale[..] { row(ui, tl!("Scale"), format!("H: {x:.1}% V: {y:.1}%")); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 14605617..c3a6f75f 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -160,11 +160,20 @@ fn open_dialog(app: &mut VectorcraftApp, files: Vec) { "template": false, "replace": false, "__replace": files.len() == 1 && one_object, + "__documents": files.iter().any(is_vectorcraft_file), + "__others": !files.iter().all(is_vectorcraft_file), "__info": info, }), )); } +/// Is dialog file entry `file` a VectorCraft document on disk (what Link places as one locked +/// object)? +fn is_vectorcraft_file(file: &Value) -> bool { + let native = ["vectorcraft", vectorcraft_format::LEGACY_EXTENSION, "vctemplate"]; + file.get("path").and_then(Value::as_str).is_some_and(|p| native.contains(&fileio::extension(p).as_str())) +} + /// The engine params naming `file` (a dialog file entry). fn source(app: &VectorcraftApp, file: &Value) -> Option { if let Some(path) = file.get("path").and_then(Value::as_str) { diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 1f751697..f23ea87c 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -361,3 +361,40 @@ fn the_control_bars_mask_clips_the_image_and_selects_the_clipping_path() { let ids: Vec = group.children().unwrap().iter().map(|c| c.id.0).collect(); assert_eq!((ids[1], st.selection.objects.iter().map(|i| i.0).collect::>()), (img, vec![ids[0]])); } + +#[test] +fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() { + let mut app = app(); + // A one-artboard document with a rectangle. + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + let bytes = vectorcraft_format::save_file(&src.active().unwrap().doc); + let path = temp_file("badge.vectorcraft", &bytes); + place_picked(&mut app, &path); + let d = app.ui.dialog.as_ref().expect("the Place dialog"); + assert!(d.bool("link") && d.bool("__documents") && !d.bool("__others"), "Link on, for a document"); + // Link's tooltip says what it does for a document. + assert!(crate::dialogs::place::link_tip(d).contains("editable copy")); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let id = st.selection.objects[0]; + let vectorcraft_doc::NodeKind::PlacedDocument(p) = &st.doc.node(id).unwrap().kind else { panic!("not a placed document") }; + assert_eq!(p.link.path, path); + // Edit Original opens the document in a new tab. + let tabs = app.session.documents().len(); + app.run("links.editOriginal", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), tabs + 1); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str())); + // A PNG keeps the image tooltip; a PNG and a document, both. + let pic = temp_file("pic.png", &png(10, 10, 72.0)); + place_picked(&mut app, &pic); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.bool("__documents") && crate::dialogs::place::link_tip(d).contains("image file")); + app.ui.dialog = None; + let both = vec![pic.clone(), path.clone()]; + app.services.pick_open_multi = Some(Box::new(move || both.clone())); + app.run("file.place", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents")); +} diff --git a/docs/mcp.md b/docs/mcp.md index 9481183d..06085afa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1424,8 +1424,11 @@ passed straight back to `file.new`. Print presets and sizes without `units` star `file.place` puts another file's art into the active document as one undo step without touching the clipboard: a raster image at 100% of its physical size (the resolution its file declares, else 72 ppi; linked to its `path` -unless `link: false`), an SVG as one group, a PDF/.ai page or a native document's artboard (`page`, `crop`) as one -clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace` +unless `link: false`), an SVG as one group, a PDF/.ai page as one clipped group, with the images, symbols, patterns +and swatches it uses. A VectorCraft document read from `path` is a placed document: one locked object showing its +artboard `page` (or, with `crop: "bounding"`, its art's bounds), linked to the file and read again when it changes +(see Linked images), and vectors in every output; with `link: false` (or from `dataBase64`) it is an editable copy +of its art, as one clipped group. `at` centres it, `rect` fits it, `replace` swaps the selected object (keeping its place and transform), `template` puts it on a new template layer. `file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it @@ -1534,6 +1537,14 @@ in `updatedLinks`), each as `{name, path, ids}`. `links.check` reports every lin another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a file system (the web), linked images show their previews. +Placed documents (a `.vectorcraft` file placed linked) work the same way: the document keeps the file's bytes and a +preview (JPEG or PNG, 1 px per point, at most 1024 px a side), and a save writes the preview (Include Linked Files: +the file); output reads the file again, or uses the preview with a warning when it is gone or changed. +`links.list` and `links.info` give them `document: true` and the artboard's `pageWidth` and `pageHeight` (pt). +Saving the file in the app updates the open documents that place it. `links.embed` (the Links panel's Break Link) +turns one into the editable copy placing without link gives, its symbols, patterns, swatches and images joining +the document; so does `object.expand`. + ```json {"name":"run_command","arguments":{"command":"links.check","params":{}}} {"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/new/photo.png"}}} From 1d013959cba9cf8224eca415b2db4afeb53b4697 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 02:29:26 +0700 Subject: [PATCH 13/74] Fix #530: Edit Artboards adds, deletes and leaves artboards from Properties The Properties panel's Edit Artboards turned on the Artboard tool but the panel went on showing the document's sections, with no way there to add an artboard (only dragging on the canvas, which the hint bar names, or the Artboards panel's button). While the Artboard tool is on, the panel now shows the active artboard: its name and X, Y, W and H (editable, artboard.setProps), New Artboard (which becomes the active one), Delete Artboard (with more than one), and Exit back to the Selection tool. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/artboards.rs | 4 +- crates/ui-egui/src/panels/properties.rs | 104 ++++++++++++++++++++++++ 8 files changed, 116 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ffeada5b..f510d5bc 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 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); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after 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), Edit Artboards showing the active artboard in the Properties panel with New Artboard, Delete Artboard and Exit (#530); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a2fc2160..c6192492 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1492,6 +1492,8 @@ New Art Brush from Selection Nuevo pincel de arte a partir de la selección New Art Has Basic Appearance La nueva ilustración tiene apariencia básica New Artboard Nueva mesa de trabajo + Artboard Mesa de trabajo + Exit Salir New Bristle Brush Nuevo pincel de cerdas New Brush… Nuevo pincel… New Calligraphic Brush Nuevo pincel caligráfico diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8ea33549..1d0271ac 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1492,6 +1492,8 @@ New Art Brush from Selection Nuovo pennello artistico dalla selezione New Art Has Basic Appearance La nuova grafica ha aspetto di base New Artboard Nuova tavola da disegno + Artboard Tavola da disegno + Exit Esci New Bristle Brush Nuovo pennello a setole New Brush… Nuovo pennello… New Calligraphic Brush Nuovo pennello calligrafico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1acb91c0..4db1d739 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1635,6 +1635,8 @@ New Art Brush from Selection 選択範囲から新規アートブラシ New Art Has Basic Appearance 新規アートに基本アピアランスを使用 New Artboard 新規アートボード + Artboard アートボード + Exit 終了 New Bristle Brush 新規剛毛ブラシ New Brush… 新規ブラシ… New Calligraphic Brush 新規絵筆ブラシ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ec6f085f..4b50d741 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1469,6 +1469,7 @@ New Art Brush from Selection 从选区新建艺术画笔 New Art Has Basic Appearance 新建图稿使用基本外观 New Artboard 新建画板 + Exit 退出 New Bristle Brush 新建鬃毛画笔 New Brush… 新建画笔… New Calligraphic Brush 新建书法画笔 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 737cb4e9..f02152f3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1509,6 +1509,8 @@ New Art Brush from Selection 從選取範圍新增藝術筆刷 New Art Has Basic Appearance 新圖稿具有基本外觀 New Artboard 新增工作區域 + Artboard 工作區域 + Exit 結束 New Bristle Brush 新增鬃毛筆刷 New Brush… 新增筆刷… New Calligraphic Brush 新增書法筆刷 diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 3dacd3f5..6eda05a3 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -13,13 +13,13 @@ use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; /// The active artboard (of `n`). -fn selected(app: &VectorcraftApp, n: usize) -> usize { +pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize { app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1)) } /// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it /// is the tool, the Artboard tool's. -fn select(app: &mut VectorcraftApp, i: usize) { +pub(crate) fn select(app: &mut VectorcraftApp, i: usize) { if let Some(v) = app.view_mut() { v.artboard = i; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 158e3317..b74bc2ba 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -18,7 +18,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let n_sel = st.selection.len(); let anchors = crate::chrome::anchor_controls(app); let first = first_selected(app); + // Edit Artboards (the Artboard tool): the active artboard, whatever is selected. + let editing_artboards = app.session.tool_id() == "artboard"; let label = match (&first, n_sel) { + _ if editing_artboards => tl!("Artboard").to_string(), (None, _) => tl!("Document").to_string(), (_, n) if n > 1 => crate::i18n::tn(n as u64, "{n} Object", "{n} Objects"), (Some(n), _) if crate::panels::image_trace::is_trace(n) => tl!("Image Tracing").to_string(), @@ -26,6 +29,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); ui.add_space(4.0); + if editing_artboards { + artboard_sections(app, ui); + return; + } if first.is_none() { document_sections(app, ui); return; @@ -188,6 +195,48 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { } } +/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, New Artboard +/// and Delete Artboard, and Exit back to the Selection tool (#530). +fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) { + let units = app.session.general_unit(); + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let i = super::artboards::selected(app, n); + if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() { + // Its name as it is (names are never translated). + ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text)); + let fw = ((ui.available_width() - 50.0) / 2.0).clamp(60.0, 110.0); + egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { + let r = ab.rect; + for (row, [(l1, k1, v1), (l2, k2, v2)]) in + [[("X:", "x", r.x0), ("W:", "width", r.width())], [("Y:", "y", r.y0), ("H:", "height", r.height())]].into_iter().enumerate() + { + for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] { + dim_label(ui, label); + if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) { + app.run("artboard.setProps", json!({"index": i, key: v})).ok(); + } + } + ui.end_row(); + } + }); + } + ui.add_space(4.0); + let w = (ui.available_width() - 6.0) / 2.0; + ui.horizontal(|ui| { + if widgets::flat_button(ui, tl!("New Artboard"), w).clicked() && app.run("artboard.new", json!({})).is_ok() { + super::artboards::select(app, n); + } + if ui.add_enabled_ui(n > 1, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() { + app.run("artboard.delete", json!({"index": i})).ok(); + } + }); + divider(ui); + section_header(ui, tl!("Quick Actions")); + if widgets::flat_button(ui, tl!("Exit"), w).clicked() { + app.select_tool("selection"); + } +} + fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { let units = app.session.general_unit(); section_header(ui, tl!("Document")); @@ -416,3 +465,58 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { } }); } + +#[cfg(test)] +mod tests { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + use vectorcraft_engine::Session; + + use super::*; + + /// One frame of the panel with `events` → the texts painted, with their rects. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|s| { + if let egui::Shape::Text(t) = &s.shape { + Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))) + } else { + None + } + }) + .collect() + } + + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) { + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), b(true)]); + frame(app, ctx, vec![b(false)]); + } + + /// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit. + #[test] + fn edit_artboards_adds_artboards_and_exits() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Edit Artboards"); + assert_eq!(app.session.tool_id(), "artboard"); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}"); + click(&mut app, &ctx, &texts, "New Artboard"); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len(); + assert_eq!(boards(&app), 2); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}"); + click(&mut app, &ctx, &texts, "Delete Artboard"); + assert_eq!(boards(&app), 1); + let texts = frame(&mut app, &ctx, vec![]); + click(&mut app, &ctx, &texts, "Exit"); + assert_eq!(app.session.tool_id(), "selection"); + } +} From d1d78e61da7c2a49de320dd4af3af1be4f808ab7 Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 20:50:54 +0100 Subject: [PATCH 14/74] M0.CI: restore checks without optional fonts --- crates/engine/src/cmd/typecmd.rs | 5 ++++- crates/plugins/src/manifest.rs | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7c830ff7..d5a2eff3 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -530,7 +530,10 @@ mod area_tests { s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); let t = text(&s); assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); - assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); + // The fallback used without craft-fonts has no glyph metrics to move the first line. + if !vectorcraft_text::CRAFT_FONTS.is_empty() { + assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); + } assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); s.execute("edit.undo", &json!({})).unwrap(); assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index e38bb579..e5d76766 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -139,10 +139,8 @@ impl Manifest { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); } match spec { - ParamSpec::Number { min, max, default } => { - if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { - return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); - } + ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); } ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), ParamSpec::Choice { options, default } => { From cb61bdf61f344fc4d1a55f9d5a872c3fc37e4fe1 Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 20:50:54 +0100 Subject: [PATCH 15/74] M14.8: dismiss macOS native menus after selection or outside input --- ROADMAP.md | 6 +- apps/vectorcraft/Cargo.toml | 2 +- apps/vectorcraft/src/mac_menu.rs | 124 ++++++++++++++++++++++++++++++- apps/vectorcraft/src/main.rs | 4 + 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a0f3eddd..07cc3959 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish and Italian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. + - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - 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. @@ -182,7 +182,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | | | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** | @@ -221,7 +221,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% | 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 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, 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 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); 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) | 5–8 | | **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/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 0ec3881e..b3f37d3a 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -41,7 +41,7 @@ clipboard-win = { workspace = true } # with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] } # 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" diff --git a/apps/vectorcraft/src/mac_menu.rs b/apps/vectorcraft/src/mac_menu.rs index 0c8325dc..0b94fa03 100644 --- a/apps/vectorcraft/src/mac_menu.rs +++ b/apps/vectorcraft/src/mac_menu.rs @@ -12,16 +12,83 @@ //! - Hide and Hide Others are items of ours calling `NSApplication`: AppKit's own always take ⌘H //! (View › Hide Edges) and ⌥⌘H. +use std::sync::Once; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; +use block2::RcBlock; + use egui::{Key, KeyboardShortcut}; use muda::accelerator::{Accelerator, Code, Modifiers}; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use objc2::MainThreadMarker; -use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType}; +use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType, NSMenuDidBeginTrackingNotification, NSMenuDidEndTrackingNotification}; +use objc2_foundation::{NSNotification, NSNotificationCenter}; use vectorcraft_ui_egui::VectorcraftApp; use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, Node, Standard}; +// AppKit can deliver window input while its system menu is still tracking (especially with +// asynchronous menu presentation). Keep the native tracking state separate from egui popups. +static TRACKING: AtomicBool = AtomicBool::new(false); + +fn observe_tracking(ctx: &egui::Context) { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + // SAFETY: AppKit initializes these immutable notification-name constants. + #[allow(unsafe_code)] + let notifications = unsafe { [(NSMenuDidBeginTrackingNotification, true), (NSMenuDidEndTrackingNotification, false)] }; + for (name, tracking) in notifications { + let repaint = ctx.clone(); + let block = RcBlock::new(move |_: std::ptr::NonNull| { + TRACKING.store(tracking, Ordering::Relaxed); + // Apply any deferred menu updates as soon as the tracking session ends. + if !tracking { + repaint.request_repaint(); + } + }); + // SAFETY: these are AppKit's notification names. The notification center retains + // the observer and block for the application's lifetime; callbacks update an atomic and request repaint. + #[allow(unsafe_code)] + unsafe { + NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(name), None, None, &block); + } + } + }); +} + +fn cancel_tracking() { + if let Some(mtm) = MainThreadMarker::new() + && let Some(menu) = NSApplication::sharedApplication(mtm).mainMenu() + { + menu.cancelTracking(); + } + TRACKING.store(false, Ordering::Relaxed); +} + +/// A dismissing click belongs to the menu, so the canvas and panel controls must not see it. +fn dismiss_input(raw: &mut egui::RawInput, tracking: bool) -> bool { + if !raw + .events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })) + { + return false; + } + if tracking { + raw.events.retain(|e| !matches!(e, egui::Event::PointerButton { .. } | egui::Event::Key { key: egui::Key::Escape, .. })); + } + true +} + +/// Inspect real window input before native key equivalents and control events are added. +pub fn raw_input_hook(raw: &mut egui::RawInput) { + // Asynchronous menu presentation may leave a displayed menu after its tracking notification. + // Still cancel it, but consume the dismissing input only during a reported tracking session. + if dismiss_input(raw, TRACKING.load(Ordering::Relaxed)) { + cancel_tracking(); + } +} + enum Handle { Plain(MenuItem), Check(CheckMenuItem), @@ -48,10 +115,13 @@ pub struct MacMenu { entries: Vec, rx: Receiver, structure: u64, + /// Latest menu state to apply after AppKit finishes tracking; item ids stay stable meanwhile. + pending: Option, } impl MacMenu { fn new(ctx: &egui::Context) -> MacMenu { + observe_tracking(ctx); let (tx, rx) = channel(); let repaint = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { @@ -67,7 +137,7 @@ impl MacMenu { // Wake egui, so the item runs now rather than on the next mouse move. repaint.request_repaint(); })); - MacMenu { menu: None, entries: Vec::new(), rx, structure: 0 } + MacMenu { menu: None, entries: Vec::new(), rx, structure: 0, pending: None } } /// Build the menus of `bar` and make them the app's, in place of the ones before. @@ -146,6 +216,13 @@ fn append(sub: &Submenu, nodes: &[Node], entries: &mut Vec) -> muda::Resu impl Backend for MacMenu { fn sync(&mut self, bar: &MenuBar) { + // Replacing or mutating an NSMenu during tracking can strand its displayed menu. Keep + // the latest state: the shared model considers this sync complete and may not resend it. + if TRACKING.load(Ordering::Relaxed) { + self.pending = Some(bar.clone()); + return; + } + self.pending = None; let items = bar.items(); if self.menu.is_none() || native_menu::structure_key(bar) != self.structure || items.len() != self.entries.len() { if let Err(e) = self.build(bar) { @@ -183,6 +260,7 @@ impl Backend for MacMenu { fn drain(&mut self) -> Vec { let mut out = Vec::new(); while let Ok(chosen) = self.rx.try_recv() { + cancel_tracking(); // Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled. let Some(e) = chosen.id.strip_prefix("vc").and_then(|n| n.parse::().ok()).and_then(|i| self.entries.get(i)) else { continue }; if let Some(command @ (native_menu::HIDE | native_menu::HIDE_OTHERS)) = e.item.command { @@ -207,6 +285,12 @@ impl Backend for MacMenu { None => Event::Click(e.item.clone()), }); } + // Drain against the old item ids before a deferred structure change replaces them. + if !TRACKING.load(Ordering::Relaxed) + && let Some(bar) = self.pending.take() + { + self.sync(&bar); + } out } } @@ -364,4 +448,40 @@ mod tests { assert_ne!(parse("Cmd+M"), parse("Ctrl+Cmd+M")); assert!(parse("Cmd+M").is_some()); } + + fn press() -> egui::Event { + egui::Event::PointerButton { + pos: egui::pos2(300.0, 200.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + } + } + + #[test] + fn outside_press_dismisses_without_starting_a_canvas_gesture() { + let moved = egui::Event::PointerMoved(egui::pos2(300.0, 200.0)); + let mut raw = egui::RawInput { events: vec![moved.clone(), press()], ..Default::default() }; + assert!(dismiss_input(&mut raw, true)); + assert_eq!(raw.events, [moved]); + assert!(!dismiss_input(&mut raw, true)); + raw.events.push(press()); + assert!(dismiss_input(&mut raw, false)); + assert_eq!(raw.events.len(), 2, "ordinary canvas presses still reach egui"); + } + + #[test] + fn escape_dismisses_without_reaching_the_active_tool() { + let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let mut raw = egui::RawInput { events: vec![escape(true), escape(false)], ..Default::default() }; + assert!(dismiss_input(&mut raw, true)); + assert!(raw.events.is_empty()); + } + + #[test] + fn moving_the_pointer_does_not_dismiss_a_tracking_menu() { + let mut raw = egui::RawInput { events: vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))], ..Default::default() }; + assert!(!dismiss_input(&mut raw, true)); + assert_eq!(raw.events.len(), 1); + } } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 8a5537a7..9cf5c5fd 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -68,6 +68,10 @@ impl eframe::App for App { } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(target_os = "macos")] + if self.app.services.native_menu.is_some() { + mac_menu::raw_input_hook(raw); + } self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { From 94ecbec9fc73e35577926afb015d55f3e355b158 Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 20:50:54 +0100 Subject: [PATCH 16/74] M14.8: opt in to public GitHub profile-name credit --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index ac861d43..26f68cda 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,3 +25,6 @@ sync_github_name = false [people.callmeBalloch] real_name = "Riccardo Balloch" sync_github_name = false + +[people.mo0kid] +sync_github_name = true From 0d68c1283adb71265d28479422c49a575eab8457 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:03:48 -0700 Subject: [PATCH 17/74] M7: keep ligatures under small tracking Only real letterspacing (|tracking| > LIGATURE_TRACKING_LIMIT = 50/1000 em) drops standard ligatures now, and an explicit liga/clig in the character's features always wins. The OpenType panel's hint follows the same rule. --- crates/text/src/features.rs | 37 +++++++++++++++++++++++++-- crates/text/src/lib.rs | 2 +- crates/text/src/tests_typo.rs | 19 +++++++++++--- crates/ui-egui/src/panels/opentype.rs | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 9e47666d..ef446dde 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -3,13 +3,25 @@ use harfrust::{Feature, Tag}; use vectorcraft_doc::CharStyle; +/// Tracking (in 1/1000 em) beyond which standard ligatures are dropped automatically. +/// +/// Letterspaced type should not ligate: an `fi` glyph keeps its letters tight while everything +/// around it opens up, which reads as a blot. Small tracking adjustments made to fit a line +/// (a few thousandths either way) are not letterspacing and keep the ligatures, so only +/// `|tracking|` above this threshold suppresses them. An explicit `liga` / `clig` in the +/// character's own features always wins. +pub const LIGATURE_TRACKING_LIMIT: f64 = 50.0; + /// OpenType features applied during shaping. Kerning, `case` and ligature suppression are driven /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. +/// Standard ligatures are dropped when `|tracking|` exceeds [`LIGATURE_TRACKING_LIMIT`], unless +/// the character's features turn `liga` (or `clig`) on explicitly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OtFeatures { /// Vertical glyph alternates (`vert`), set by the writing direction. pub vertical: bool, - /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero. + /// Standard ligatures (`liga`, `clig`). Suppressed automatically when `|tracking|` exceeds + /// [`LIGATURE_TRACKING_LIMIT`], unless the character turns them on explicitly. pub ligatures: bool, /// Contextual alternates (`calt`). pub contextual: bool, @@ -117,7 +129,7 @@ impl OtFeatures { if st.kerning.is_some() { v.push(f(b"kern", false)); } - let liga = s.ligatures && st.tracking.abs() < 1e-9; + let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(&st.features)); if !liga { v.push(f(b"liga", false)); v.push(f(b"clig", false)); @@ -150,6 +162,27 @@ impl OtFeatures { } } +/// Does `tracking` (1/1000 em) count as letterspacing that drops standard ligatures? +pub fn ligatures_suppressed_by(tracking: f64) -> bool { + // A non-finite value is not a real letterspacing request; keep the default. + tracking.is_finite() && tracking.abs() > LIGATURE_TRACKING_LIMIT +} + +/// Do `features` turn standard ligatures on explicitly (`liga`, `+liga`, `clig`, `+clig`)? The last +/// ligature tag wins, matching [`OtFeatures::with_tags`]. Absence of a tag means "default", which +/// [`OtFeatures::to_tags`] never writes, so this is distinguishable from an explicit request. +pub fn explicit_ligatures>(features: &[S]) -> bool { + features + .iter() + .rev() + .find_map(|t| match t.as_ref() { + "liga" | "+liga" | "clig" | "+clig" => Some(true), + "-liga" | "-clig" => Some(false), + _ => None, + }) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7833601a..cb79ed0f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -23,7 +23,7 @@ pub mod test_fonts; pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; -pub use features::OtFeatures; +pub use features::{LIGATURE_TRACKING_LIMIT, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index dcd91800..a486224f 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -370,9 +370,22 @@ fn opentype_ligature_switches() { assert_eq!(layout(db(), &t).glyphs.len(), 1, "fi ligature by default"); let off = LayoutOptions { features: OtFeatures { ligatures: false, ..Default::default() }, ..Default::default() }; assert_eq!(layout_with(db(), &t, &off).glyphs.len(), 2); - // Tracking suppresses ligatures (as letterspaced type should). - let tracked = TextObject::point(Point::ZERO, "fi", CharStyle { tracking: 100.0, ..serif }); - assert_eq!(layout(db(), &tracked).glyphs.len(), 2); + let fi = |tracking: f64, features: &[&str]| { + let st = CharStyle { tracking, features: features.iter().map(|s| s.to_string()).collect(), ..serif.clone() }; + layout(db(), &TextObject::point(Point::ZERO, "fi", st)).glyphs.len() + }; + // Small tracking adjustments (fitting a line) keep the ligature. + assert_eq!(fi(-5.0, &[]), 1, "tracking -5 keeps fi"); + assert_eq!(fi(20.0, &[]), 1, "tracking +20 keeps fi"); + assert_eq!(fi(LIGATURE_TRACKING_LIMIT, &[]), 1, "the limit itself keeps fi"); + // Real letterspacing suppresses ligatures (as letterspaced type should)... + assert_eq!(fi(100.0, &[]), 2, "tracking 100 drops fi"); + assert_eq!(fi(-100.0, &[]), 2, "tracking -100 drops fi"); + // ...unless the character turns them on explicitly. + assert_eq!(fi(100.0, &["liga"]), 1, "explicit liga wins over tracking"); + assert_eq!(fi(100.0, &["+liga"]), 1); + assert_eq!(fi(100.0, &["liga", "-liga"]), 2, "the last tag wins"); + assert_eq!(fi(0.0, &["-liga"]), 2, "explicit off still turns it off"); let f = OtFeatures::from_tags(["dlig", "-liga", "smcp", "onum", "bogus"]); assert!(f.discretionary_ligatures && !f.ligatures && f.small_caps && f.oldstyle_figures && !f.fractions); } diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 9f737233..2be67f8e 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -63,7 +63,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { *field(&mut next) = !on; } } - if st.tracking.abs() > 1e-9 && cur.ligatures { + if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) { widgets::dim_label(ui, tl!("Ligatures are off while the text is tracked.")); } if next != cur { From 2023a47188cb3a7347de0f3377fba6e2d47fdf05 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:04:48 +0700 Subject: [PATCH 18/74] Fix #569: Shape Builder merges leave no stray edges in their outlines Merging regions united the faces' outlines with a boolean union. Each face had been refit on its own, so two faces' shared edge could come out a hair apart: the union then kept the edge between them, or a spur of it, and a stroked merged shape (unfilled art) showed a stray line inside. Subtracting the merged shape from the others left slivers along it too. pathops::FaceMerger merges faces from the planar arrangement itself, made once per merge: their contours share their edges exactly, so the edges between merged faces vanish. The Shape Builder builds the merged shape that way, and what is left of each shape from its faces not merged (rather than subtracting the merged shape from it). Selections with open paths keep the old union. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/buildcmds.rs | 15 ++++++- crates/engine/src/tests_build.rs | 64 ++++++++++++++++++++++++++++++ crates/pathops/src/lib.rs | 2 +- crates/pathops/src/pathfinder.rs | 59 ++++++++++++++++++++++++++- 5 files changed, 137 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a0f3eddd..42bc0019 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 30745fa2..921d1e84 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -250,7 +250,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { // Edges come back from the planar map within a few hundredths of the outlines they follow. let cut_tol = step * 0.2; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) }; + let faces = po::FaceMerger::new(&map.shapes); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; // The merged shape takes the look of the front-most object over the first region, or (for an // area only lines enclose) of the front-most object and the current fill. let covered = picked.iter().find_map(|r| r.top()); @@ -278,7 +279,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { // its outline, shared edges from every shape along them). let in_union = picked.iter().any(|r| r.sources.contains(&i)); let mut rest = if in_union { - po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference) + // What is left of it: its faces not merged or deleted, united. They share their + // edges with the merged faces exactly, where subtracting the merged shape left + // slivers of the outline along them (#569). + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge(&kept) } } else { b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) }; diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 04af2e0b..e077ff02 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -704,3 +704,67 @@ fn image_trace_object_remembers_its_settings() { let id = st.selection.in_paint_order(&st.doc)[0]; assert!(st.doc.node(id).unwrap().trace.is_none()); } + +/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals +/// overlapping) merges every region the drags touch into one shape whose outline is only the +/// merged area's border: no edge between merged regions stays in, as a stray line or a spur. +#[test] +fn shape_builder_merges_rosette_rings_cleanly() { + let mut s = session(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + let c = [400.0, 320.0]; + let mut petals = vec![]; + for k in 0..8 { + let id = + s.execute("shape.ellipse", &json!({"x": c[0] - 60.0, "y": c[1] - 172.0, "width": 120, "height": 324})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.rotate", &json!({"angle": 45.0 * f64::from(k), "origin": c})).unwrap(); + petals.push(id); + } + s.execute("select.set", &json!({"ids": petals})).unwrap(); + // Drags around the ring of petals, from its inside out; the outer ones miss the petals. + let mut merged = None; + for radius in [110.0, 150.0, 190.0, 230.0] { + let pts: Vec<[f64; 2]> = (0..=120) + .map(|i| { + let a = std::f64::consts::TAU * f64::from(i) / 120.0; + [c[0] + radius * a.cos(), c[1] + radius * a.sin()] + }) + .collect(); + if let Ok(r) = s.execute("shapeBuilder.merge", &json!({"points": pts})) { + merged = r["merged"].as_u64().map(NodeId); + } + } + let (ring, rule) = outline(&node(&s, merged.expect("the drags merged"))); + let inside = |p: kurbo::Point| kurbo::Shape::winding(&ring.to_bezpath(), p) != 0; + for k in 0..8 { + let a = std::f64::consts::FRAC_PI_4 * f64::from(k) - std::f64::consts::FRAC_PI_2; + let tip = kurbo::Point::new(c[0] + 165.0 * a.cos(), c[1] + 165.0 * a.sin()); + assert!(inside(tip), "petal {k}'s tip at {tip:?} is in the merged shape"); + } + // What is left of the petals stays out of the merged area (a piece left over it is a stray + // outline inside it). + let merged = merged.unwrap(); + for id in s.doc().unwrap().selection.objects.clone().into_iter().filter(|id| *id != merged) { + let (p, r) = outline(&node(&s, id)); + let over = vectorcraft_pathops::boolean(&p, r, &ring, rule, vectorcraft_pathops::BoolOp::Intersect); + let a = vectorcraft_pathops::area(&over, FillRule::NonZero); + assert!(a < 1.0, "{id:?} overlaps the merged shape by {a} pt²"); + } + // No outline is a sliver or carries a spur (2 × area / length: its mean width). + for id in s.doc().unwrap().selection.objects.clone() { + let (p, r) = outline(&node(&s, id)); + for sp in &p.subpaths { + let one = PathData::new(vec![sp.clone()]); + let width = 2.0 * vectorcraft_pathops::area(&one, r) / one.length(); + assert!(width > 1.0, "{id:?} has an outline {width:.3} pt wide on average, {:.1} pt long", one.length()); + } + } + // The ring is one outline around the centre (the regions the drags missed aside): the + // petals' tips are no outlines of their own beside it, apart by a hairline. + let big = ring.subpaths.iter().filter(|sp| vectorcraft_pathops::area(&PathData::new(vec![(*sp).clone()]), rule) > 100.0).count(); + assert_eq!(big, 2, "{:?}", ring.subpaths.iter().map(|sp| vectorcraft_pathops::area(&PathData::new(vec![sp.clone()]), rule)).collect::>()); + // An inner edge would be a second outline over the merged area: normalising would drop it. + let clean = vectorcraft_pathops::normalize(&ring, rule); + assert!((ring.length() - clean.length()).abs() < 1.0, "{} pt of outline, {} without inner edges", ring.length(), clean.length()); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 97f00aef..0e67a96d 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -19,7 +19,7 @@ pub use edit::{ split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; -pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder}; /// Errors from fallible operations. diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index ea36e569..fa11c145 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -7,7 +7,7 @@ use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _}; use linesweeper::topology::ContourIdx; use vectorcraft_geom::{FillRule, PathData}; -use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; +use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez, segs_to_subpath, unite_all}; /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. @@ -264,6 +264,63 @@ pub fn merge_regions(regions: &[&Region]) -> PathData { unite_all(&v) } +/// Merges faces of the planar arrangement of a set of shapes (as [`regions`] gives them) into one +/// path, from the arrangement itself, made once for every merge of the set: the faces' contours +/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied +/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where +/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut) +/// fall back to [`merge_regions`]. +pub struct FaceMerger { + /// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes. + arr: Option, + shapes: usize, +} + +impl FaceMerger { + pub fn new(shapes: &[Shape]) -> Self { + let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2); + Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() } + } + + /// The union of `faces`. + pub fn merge(&self, faces: &[&Region]) -> PathData { + self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces)) + } + + fn exact(&self, faces: &[&Region]) -> Option { + let arr = self.arr.as_ref()?; + // Each face by its coverage and a point inside it. + let mut picks: Vec<(Vec, Point)> = Vec::with_capacity(faces.len()); + for r in faces { + let mut mask = vec![false; self.shapes]; + for &s in &r.sources { + *mask.get_mut(s)? = true; + } + picks.push((mask, crate::planar::interior_point(&r.path)?)); + } + let mut raw = BezPath::new(); + let mut done: Vec<&[bool]> = vec![]; + for (mask, _) in &picks { + if done.contains(&mask.as_slice()) { + continue; + } + done.push(mask); + let c = arr.contours(|k| k == mask.as_slice()); + for group in c.grouped() { + let mut face = BezPath::new(); + for i in group { + face.extend(c[i].path.iter()); + } + if picks.iter().any(|(m, p)| m == mask && face.winding(*p) != 0) { + raw.extend(face.iter()); + } + } + } + let merged = normalize_bez(&raw, FillRule::NonZero).ok()?; + Some(all_contours_to_path(&merged, &arr.tidy)) + } +} + /// Pathfinder Outline: split every shape's boundary at junctions of the arrangement. fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { let junctions = arr.junctions(); From 1e2cb082bac80c30eafe3d42332d9fe848c8b7fc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:18:07 +0700 Subject: [PATCH 19/74] Fix #526: the exports and the CLI write .ai as Save As does file.saveAs and document.save wrote a PDF-compatible .ai, but document.export (and with it the CLI's run --export x.ai and convert in.svg out.ai, and MCP's export) refused it as a format that can only be opened, and --help didn't list it as writable. The .ai encoder is now shared: save.rs's ai_params and encode_ai (a PDF of every artboard with the PDF options, carrying the native document, blank pages with pdfCompatible: false) serve Save As and the exports alike. The .ai format is writable, so document.formats, --help and the MCP export tool list it; an export leaves the document's own path alone. Export As and Export for Screens keep leaving .ai to Save As. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/encode.rs | 6 ++ crates/engine/src/cmd/fileio/mod.rs | 4 +- crates/engine/src/cmd/fileio/save.rs | 56 +++++++++++-------- crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/engine/src/cmd/fileio/tests.rs | 2 + crates/engine/src/cmd/fileio/tests_pdfedit.rs | 21 +++++++ crates/mcp/src/tests_fileio.rs | 1 + crates/ui-egui/src/dialogs/export_as.rs | 5 +- docs/mcp.md | 2 + 10 files changed, 72 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 07cc3959..64c2c842 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); 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), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 202d8867..37334fef 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -417,6 +417,12 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { "txt" => super::text::encode(doc, p)?, "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), "dxf" => return super::dxf::encode(doc, p, use_artboards), + // As Save As writes it (a PDF of every artboard carrying the native document). + "ai" => { + let mut q = p.clone(); + super::save::ai_params(&mut q); + return super::save::encode_ai(C, f, doc, &q); + } // EPS reads its own `useArtboards` (the art's bounds unless asked). "eps" => return super::eps::encode(doc, p), "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e2ae1d06..44f49f1c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -465,8 +465,8 @@ pub const FORMATS: &[Format] = &[ extensions: &["ai"], mime: "application/pdf", read: true, - // Saved (Save As), not exported. - write: false, + // Save As writes it; exports write the same file. + write: true, raster: false, options: native::AI_OPTIONS, }, diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index ed776be7..c20dbe7e 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -305,6 +305,35 @@ fn blank_pages(doc: &Document) -> Document { /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; +/// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard +/// with the PDF options, always carrying the native document; Use Compression compresses the +/// PDF's content. +pub(super) fn ai_params(params: &mut Value) { + let Some(o) = params.as_object_mut() else { return }; + o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); + if let Some(c) = o.get("compress").and_then(Value::as_bool) { + let compression = o.entry("compression").or_insert_with(|| json!({})); + if let Some(m) = compression.as_object_mut() { + m.insert("compressText".into(), json!(c)); + } + } +} + +/// Encode `doc` as a PDF-compatible `.ai` file (`params` as [`ai_params`] makes them) for command +/// `cmd`: its pages (blank with `pdfCompatible: false`, which it then notes), carrying the native +/// document. File › Save As writes it, and so do the exports (`document.export`, the CLI). +pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, params: &Value) -> Result { + let compatible = bool_or(params, "pdfCompatible", true); + let pages = doc.without_edit_modes(); + let pages = if compatible { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; + let native = || super::native::ai_native(cmd, f, doc, params); + let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, params, native)?; + if !compatible { + warnings.insert(0, NOT_PDF_COMPATIBLE.to_string()); + } + Ok(Encoded { warnings, ..Encoded::one(bytes) }) +} + /// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the /// document's own). Bad options fail here, before anything is encoded. pub fn save_job(s: &mut Session, plan: SavePlan) -> Result { @@ -339,16 +368,8 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); - let enc = if f.id == "ai" { - // The pages (blank without PDF content), carrying the native document. - let pages = doc.without_edit_modes(); - let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) }; - let native = || super::native::ai_native(cmd, f, doc, &self.params); - let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?; - Encoded { warnings, ..Encoded::one(bytes) } - } else { - encode_all(doc, f.id, &self.params)? - }; + let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? }; if enc.files.len() != 1 { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 62ff1894..b1191520 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -161,7 +161,7 @@ impl Row { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") { + if matches!(format.id, "vectorcraft" | "template" | "ai" | "txt" | "dxf" | "eps" | "emf" | "wmf") { return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf")); } // Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index ce6cca14..cecc9750 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -173,6 +173,7 @@ fn export_extensions_name_every_writable_format() { "svg", "svgz", "pdf", + "ai", "png", "jpg", "gif", @@ -206,6 +207,7 @@ fn formats_query_lists_readers_writers_and_options() { "svg", "svgz", "pdf", + "ai", "png", "jpg", "gif", diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index c2d0d2f3..99cc575e 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -118,6 +118,27 @@ fn ai_files_are_pdfs_that_save_and_reopen_editable() { assert_eq!(s.doc().unwrap().path, None); } +/// #526: the exports (`document.export`, the CLI's `--export` and `convert`) write `.ai` as Save +/// As does, leaving the document's own path alone. +#[test] +fn exports_write_ai_as_save_as_does() { + let mut s = rich(); + let before = comparable(&s.doc().unwrap().doc); + let path = tmp("exported.ai"); + let r = s.execute("document.export", &json!({"path": path})).unwrap(); + assert_eq!(r["format"], "ai", "{r}"); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document"); + assert_eq!(s.doc().unwrap().path, None, "an export doesn't give the document a path"); + let r = s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}"); + assert_eq!(comparable(&s.doc().unwrap().doc), before); + // Its options as Save As takes them; no path gives the bytes. + let r = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("without PDF content"))), "{r}"); + assert!(b64(&r).starts_with(b"%PDF")); +} + #[test] fn ait_templates_open_their_document_untitled() { let mut s = rich(); diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 54761f6a..2e7a2d01 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -62,6 +62,7 @@ fn export_tool_lists_engine_formats_and_options() { "svg", "svgz", "pdf", + "ai", "png", "jpg", "gif", diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 82e3d255..60142f96 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -17,8 +17,9 @@ use crate::{VectorcraftApp, io, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM }; -/// The formats Export As writes (the native format is Save's). -static FORMATS: LazyLock> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect()); +/// The formats Export As writes (the native format and `.ai` are Save As's). +static FORMATS: LazyLock> = + LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && !matches!(f.id, "vectorcraft" | "ai")).collect()); /// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out. const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")]; static LABELS: LazyLock> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect()); diff --git a/docs/mcp.md b/docs/mcp.md index 06085afa..ae343714 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -423,6 +423,8 @@ document exactly (`restored: true`); when another app changed the pages, or the imported instead and the first warning says why. Choosing a standard turns it off (PDF/A refuses it). Save to a `.ai` path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled. +`document.export` / `serialize` to `.ai` (and the CLI's `run --export x.ai` and `convert in.svg out.ai`) write the same file +without giving the document a path. Drive a tool like a mouse: From 2a35c4a82f26db89ae7323ff14eccde6f394d5e1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:36:08 +0700 Subject: [PATCH 20/74] Fix #522: SVG round trips keep hidden objects and data-* attributes An SVG used as an animation rig lost its hidden alternates and their rotation pivots: the exporter left hidden objects out (only hidden layers could be kept), and the importer dropped data-* attributes. - SVG export: hiddenLayers (on by default for document.save, as before) now keeps hidden objects too, each written display="none" on its own element (path, image, text or group). Exports still leave them out unless asked; the CLI's convert asks, as a conversion keeps the document. - An object's own data: ObjectAttributes.data, the data-* attributes in order (ours, data-name and data-vc-*, aside). The importer keeps them on the objects and layers they belong to (at most 64 per element), the exporter writes them back, the native format saves them (missing in older files), and object.setProps {data: {key: value|null}} sets or removes them. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- apps/vectorcraft-cli/src/main.rs | 13 +++--- crates/doc/src/node.rs | 4 ++ crates/engine/src/cmd/fileio/svg.rs | 2 +- crates/engine/src/cmd/object.rs | 51 ++++++++++++++++++++++- crates/engine/src/tests_attributes.rs | 24 +++++++++++ crates/svg/src/css.rs | 6 ++- crates/svg/src/export.rs | 29 ++++++++----- crates/svg/src/import.rs | 46 +++++++++++++++++++- crates/svg/src/lib.rs | 3 +- crates/svg/tests/symbols_layers.rs | 60 ++++++++++++++++++++++++++- docs/mcp.md | 7 ++-- 12 files changed, 218 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6712ecd7..83499938 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); 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), SVG round trips keeping hidden objects and `data-*` attributes (#522); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -156,7 +156,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). - **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); 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). + - 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); 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). - 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). - 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. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b5b6dd0c..38d917fa 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -69,7 +69,8 @@ USAGE: Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard unless one of them is given, EPS the bounds of the art, the other formats the first artboard. - Live effects are kept; --outline-text writes SVG text as paths. + Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with + SVG's data-* attributes; --outline-text writes SVG text as paths. vectorcraft-cli info FILE Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS @@ -177,12 +178,10 @@ fn convert(args: &[String]) -> Result<(), String> { let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?; let mut h = Headless::new(); h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?; - let r = h - .call( - "engine.execute", - json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}), - ) - .map_err(|e| format!("export {output}: {e}"))?; + // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG, + // PSD). + let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true}); + let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?; outln!("{r}"); Ok(()) } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 13ff5298..53d5c757 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1214,6 +1214,10 @@ pub struct ObjectAttributes { /// The note shown in the Attributes panel. #[serde(default, skip_serializing_if = "String::is_empty")] pub note: String, + /// The object's own data, in order: SVG's `data-*` attributes (`data-pivot="100,180"` is + /// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub data: Vec<(String, String)>, } /// The Attributes panel's Image Map shapes. diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 263d5fd9..773a2cb1 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -70,7 +70,7 @@ pub const OPTIONS: &[FormatOption] = &[ name: "hiddenLayers", ty: "boolean", default: "false", - description: "keep hidden layers, not displayed (display:none); document.save sets it unless given", + description: "keep hidden layers and objects, not displayed (display=\"none\"); document.save sets it unless given", }, FormatOption { name: "encoding", diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 0651a85f..65a9951a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -150,7 +150,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool}", + "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", has_doc, set_props ), @@ -738,6 +738,32 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { ok() } +/// The most data entries `object.setProps` takes at once. +const MAX_DATA: usize = 256; + +/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`, +/// `.`) with its value, or `None` to remove it. +fn data_param(m: &serde_json::Map) -> Result)>> { + const C: &str = "object.setProps"; + if m.len() > MAX_DATA { + return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once"))); + } + m.iter() + .map(|(k, v)| { + let k = k.strip_prefix("data-").unwrap_or(k); + if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") { + return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)"))); + } + let v = match v { + Value::Null => None, + Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + }; + Ok((k.to_string(), v)) + }) + .collect() +} + fn set_props(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let opacity = p.get("opacity").and_then(Value::as_f64).map(percent); @@ -745,6 +771,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result { Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), None => None, }; + let data = match p.get("data") { + None | Some(Value::Null) => None, + Some(Value::Object(m)) => Some(data_param(m)?), + Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))), + }; let knockout = match p.get("knockout") { Some(v) => Some( Knockout::from_value(v) @@ -779,6 +810,24 @@ fn set_props(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) { n.knockout_shape = v; } + if let Some(changes) = &data { + n.edit_attrs(|a| { + for (k, v) in changes { + match (a.data.iter().position(|(key, _)| key == k), v) { + (Some(i), Some(v)) => { + if let Some(e) = a.data.get_mut(i) { + e.1 = v.clone(); + } + } + (Some(i), None) => { + a.data.remove(i); + } + (None, Some(v)) => a.data.push((k.clone(), v.clone())), + (None, None) => {} + } + } + }); + } } Ok(()) })?; diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 3e7b0231..562c342a 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -190,3 +190,27 @@ fn svg_export_links_objects_and_import_reads_the_links_back() { }); assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone"); } + +/// #522: an object's own data (SVG `data-*` attributes) is set with `object.setProps {data}`, +/// undone in one step, written to SVG and kept by the native format. +#[test] +fn object_data_is_set_saved_and_written_to_svg() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default(); + let pair = |k: &str, v: &str| (k.to_string(), v.to_string()); + run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}})); + assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text"); + run(&mut s, "object.setProps", json!({"data": {"data-pivot": "1,2", "z": null}})); + assert_eq!(data(&s), [pair("pivot", "1,2")], "a data- prefix is optional; null removes a key"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(data(&s).len(), 2, "one step"); + for bad in [json!({"data": {"vc-x": "1"}}), json!({"data": {"name": "x"}}), json!({"data": {"a b": "1"}}), json!({"data": "pivot"})] { + assert!(s.execute("object.setProps", &bad).is_err(), "{bad}"); + } + let svg = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string(); + assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}"); + let native = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&native).unwrap(); + assert_eq!(back.node(a).unwrap().attrs, s.doc().unwrap().doc.node(a).unwrap().attrs); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index ee7c3247..0589f323 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -50,9 +50,13 @@ pub(crate) fn blend_css(b: BlendMode) -> &'static str { } } -/// An object's opacity, blend mode and isolation. +/// An object's opacity, blend mode and isolation, and `display: none` when it is hidden (written +/// only when hidden objects are kept). pub(crate) fn transparency(n: &Node) -> Props { let mut p = Props::new(); + if !n.visible { + p.push(("display", "none".into())); + } if n.opacity < 1.0 { p.push(("opacity", fmt_num(n.opacity as f64, 3))); } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 6eec850c..15422ed4 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -545,15 +545,25 @@ impl Writer<'_> { } /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to /// differ from it (spaces, punctuation, duplicates, the unique prefix). + /// The id of `n`'s element (with its name when the id isn't it), then the object's own data + /// as `data-*` attributes. fn id_attr(&self, n: &Node) -> String { if self.anonymous { return String::new(); } - let Some(id) = self.names.get(&n.id) else { return String::new() }; - match n.name.as_deref() { - Some(name) if name != id => format!(" id=\"{}\" data-name=\"{}\"", xml_escape(id), xml_escape(name)), - _ => format!(" id=\"{}\"", xml_escape(id)), + let mut out = match (self.names.get(&n.id), n.name.as_deref()) { + (Some(id), Some(name)) if name != id => format!(" id=\"{}\" data-name=\"{}\"", xml_escape(id), xml_escape(name)), + (Some(id), _) => format!(" id=\"{}\"", xml_escape(id)), + (None, _) => String::new(), + }; + for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + // Names as XML takes them; ours (`name`, `vc-…`) are written by the export itself. + let valid = k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && k != "name" && !k.starts_with("vc-"); + if valid && !k.is_empty() { + out.push_str(&format!(" data-{k}=\"{}\"", xml_escape(v))); + } } + out } fn matrix(&self, m: Affine) -> String { let c = m.as_coeffs(); @@ -1130,15 +1140,12 @@ impl Writer<'_> { std::mem::replace(&mut self.body, body) } - /// Props of a group (or layer): a knockout group is isolated, a hidden layer not displayed. + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. fn group_props(&self, n: &Node) -> Props { let mut p = css::transparency(n); if !n.isolate && n.knocks_out(self.knockout) { p.push(("isolation", "isolate".into())); } - if !n.visible { - p.push(("display", "none".into())); - } p } @@ -1221,9 +1228,9 @@ impl Writer<'_> { } fn node_body(&mut self, n: &Node) { - // Hidden objects are left out; hidden layers too, unless the options keep them (hidden). - let kept = self.opts.hidden_layers && matches!(n.kind, NodeKind::Layer { template: false, .. }); - if !(n.visible || kept) { + // Hidden layers and objects are left out, unless the options keep them (hidden). Template + // layers never print. + if !(n.visible || self.opts.hidden_layers) { return; } if has_raster(&n.appearance.effects) { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 5c400e9b..e796891d 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -64,6 +64,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec, + /// The `data-*` attributes of elements, by id ([`user_data`]). + data: HashMap>, /// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling /// stroke's width is measured in. screen: f64, @@ -320,6 +324,8 @@ struct Found { files: files::Files, /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). non_scaling: HashSet, + /// The `data-*` attributes of elements, by id ([`user_data`]). + data: HashMap>, warnings: Vec, } @@ -390,7 +396,7 @@ impl Edits { fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) - if !["(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { found.links.insert(edits.id(svg, a), url.to_string()); } } + if svg.contains("data-") { + for e in elements().filter(|e| OBJECT_ELEMENTS.contains(&e.tag_name().name())) { + let data = user_data(e); + if !data.is_empty() { + found.data.insert(edits.id(svg, e), data); + } + } + } let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect(); for u in elements().filter(|n| n.tag_name().name() == "use") { if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) { @@ -456,6 +470,24 @@ fn tag_name_end(svg: &str, n: XNode) -> usize { svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i) } +/// The elements that become objects, whose `data-*` attributes are kept. +const OBJECT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image"]; + +/// The most `data-*` attributes kept per element (the file is untrusted). +const MAX_DATA: usize = 64; + +/// The `data-*` attributes of element `e`, in order, without the `data-` (ours, `data-name` and +/// `data-vc-*`, aside). +fn user_data(e: XNode) -> Vec<(String, String)> { + e.attributes() + .filter(|a| a.namespace().is_none()) + .filter_map(|a| Some((a.name().strip_prefix("data-")?, a.value()))) + .filter(|(k, _)| !k.is_empty() && *k != "name" && !k.starts_with("vc-")) + .take(MAX_DATA) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + /// Make `n` link to `url`, unless it links somewhere already (an inner link wins). fn link(n: &mut Node, url: &str) { if n.url().is_none() { @@ -592,9 +624,18 @@ impl Importer { if self.hidden.contains(n.id()) { out.visible = false; } + self.keep_data(n.id(), &mut out); Some(out) } + /// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an + /// inner element made of it keeps). + fn keep_data(&self, id: &str, n: &mut Node) { + if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) { + n.edit_attrs(|a| a.data = data.clone()); + } + } + /// A gradient's stops, midpoints restored. fn stops(&self, g: &usvg::BaseGradient) -> Vec { gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice)) @@ -1114,9 +1155,10 @@ impl Importer { std::mem::take(&mut self.uses), std::mem::take(&mut self.labels), std::mem::take(&mut self.non_scaling), + std::mem::take(&mut self.data), ); let art = self.group_node(tree.root(), acc); - (self.links, self.hidden, self.uses, self.labels, self.non_scaling) = outer; + (self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data) = outer; art } } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index e5d85966..aae41bda 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -208,7 +208,8 @@ pub struct ExportOptions { /// One positioned `` per line of type instead of one per style run, tab stop and /// justified word (smaller; viewers then space the line with their own font metrics). pub fewer_tspans: bool, - /// Keep hidden layers, written hidden (`display:none`), as Save does; exports leave them out. + /// Keep hidden layers and objects, written hidden (`display="none"`), as Save does; exports + /// leave them out unless asked. pub hidden_layers: bool, /// The file's character encoding (see [`Output::bytes`]). pub encoding: Encoding, diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs index 4c72dddb..e424c25e 100644 --- a/crates/svg/tests/symbols_layers.rs +++ b/crates/svg/tests/symbols_layers.rs @@ -1,6 +1,7 @@ //! Symbols export as one `` def and a `` per instance (instances the def can't stand -//! for get their own art), hidden layers are kept hidden when asked and come back hidden, clipped -//! layers come in as clipping layers, and the editing data notices edits made elsewhere. +//! for get their own art), hidden layers and objects are kept hidden when asked and come back +//! hidden with their `data-*` attributes, clipped layers come in as clipping layers, and the +//! editing data notices edits made elsewhere. // 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)] @@ -205,6 +206,61 @@ fn hidden_layers_are_kept_hidden_when_asked() { } } +/// An animation rig (#522): hidden alternates (a group and a lone path) and rotation pivots kept as +/// `data-*` attributes. +const RIG: &str = r##" + + + + + +"##; + +/// The `data-*` attributes of the object named `name`. +fn data_of(d: &Document, name: &str) -> Vec<(String, String)> { + let mut found = vec![]; + d.walk(|n| { + if n.name.as_deref() == Some(name) { + found = n.attrs.as_deref().map(|a| a.data.clone()).unwrap_or_default(); + } + }); + found +} + +#[test] +fn hidden_objects_and_data_attributes_round_trip() { + let d = import(RIG).unwrap(); + let pivot = |v: &str| vec![("pivot".to_string(), v.to_string())]; + assert_eq!(data_of(&d, "character"), pivot("100,180")); + assert_eq!(data_of(&d, "arm"), [("pivot".to_string(), "60,100".to_string()), ("z-order".to_string(), "2".to_string())]); + assert_eq!(data_of(&d, "arm-raised"), pivot("60,100")); + let hidden = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if !n.visible { + v.push(n.name.clone().unwrap_or_default()); + } + }); + v + }; + assert_eq!(hidden(&d), ["arm-raised", "eye-closed"]); + // An export leaves the hidden objects out, their data with them; the others keep theirs. + let plain = export(&d, &ExportOptions::default()); + assert!(!plain.contains("arm-raised") && !plain.contains("eye-closed") && plain.contains("data-pivot=\"60,100\""), "{plain}"); + let styles = [vectorcraft_svg::Styling::PresentationAttributes, vectorcraft_svg::Styling::InlineStyle, vectorcraft_svg::Styling::InternalCss]; + for styling in styles { + // Kept (Save, or asked), they are written hidden and come back hidden, with every attribute. + let kept = export(&d, &ExportOptions { hidden_layers: true, styling, ..Default::default() }); + assert!(kept.contains("data-pivot=\"100,180\"") && kept.contains("data-z-order=\"2\"") && kept.contains("data-blink=\"1\""), "{kept}"); + assert_eq!(resvg_render(&kept, 200, 200).pixel(100, 85), [255, 255, 255, 255], "hidden, not drawn: {kept}"); + let back = import(&kept).unwrap(); + assert_eq!(hidden(&back), ["arm-raised", "eye-closed"], "{kept}"); + for name in ["character", "arm", "arm-raised", "eye-closed"] { + assert_eq!(data_of(&back, name), data_of(&d, name), "{name}: {kept}"); + } + } +} + /// Each layer's art: clipping layer or not, and the kinds of its children. fn layer_art(d: &Document) -> Vec<(String, bool, Vec<&'static str>)> { d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.clips(), l.children().unwrap().iter().map(|c| c.kind_label()).collect())).collect() diff --git a/docs/mcp.md b/docs/mcp.md index 06085afa..f6e72745 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -266,9 +266,10 @@ place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves Symbols export as one `` with a `` per instance. An instance the def can't stand for is written as its own art: one stained by its fill; one scaled while the symbol has strokes (their weight doesn't scale), or rotated or scaled while it has effects, brushes or live objects; and every instance of a symbol with pattern paints or unlinked -opacity masks (those stay on the page). `hiddenLayers: true` keeps hidden layers as groups that aren't displayed -(`display:none`); `document.save` keeps them unless told otherwise, exports leave them out, and they reopen as -hidden layers. A `preserveEditing` SVG carries the native document (CDATA in ``) and a hash of the +opacity masks (those stay on the page). `hiddenLayers: true` keeps hidden layers and objects, not displayed +(`display="none"`); `document.save` and the CLI's `convert` keep them unless told otherwise, other exports leave +them out, and they reopen hidden. An object's own data (`object.setProps {data: {pivot: "100,180"}}`) is written as +`data-*` attributes (`data-pivot="100,180"`), and an SVG's `data-*` attributes come in as it. A `preserveEditing` SVG carries the native document (CDATA in ``) and a hash of the markup around it: if another app changed the SVG since, `document.open` reads it as plain SVG and says so in `warnings`. From 89b10cce529ad30d654508eee5d62a87b11f8f5f Mon Sep 17 00:00:00 2001 From: MaksPV Date: Thu, 8 Oct 2026 23:44:45 +0300 Subject: [PATCH 21/74] Add Russian (ru) interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ru.tsv with the full interface coverage: every menu string, every tl! literal, the plurals (one/few/many) and the status/error messages (@msg). Registered as complete_menus with the Russian plural rule; every ru-* locale resolves to it. Keeps the product, workspace and perspective preset names in English like the other complete catalogs. Tests: russian_reads_as_russian, russian_plurals_have_three_forms, russian_glyphs_are_available_without_system_fonts; ru added to KEEPS_MENU_NAMES and COMPLETE_MESSAGES. Clean-room: written from the meaning of the English labels in the vocabulary Russian-speaking users of vector illustration software know (Слой, Образцы, Обработка контуров, Монтажная область, Обводка, Заливка), matching the Russian catalog of the sibling app PhotoCraft. No other application's localisation was copied. --- README.md | 2 +- ROADMAP.md | 4 +- crates/ui-egui/src/i18n/mod.rs | 19 +- crates/ui-egui/src/i18n/ru.tsv | 3598 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 60 +- docs/development.md | 9 +- 6 files changed, 3682 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ru.tsv diff --git a/README.md b/README.md index 1f87e190..109e2532 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ milestones, and honest time-to-parity estimates. "a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface -speaks English, Japanese, Traditional and Simplified Chinese, Spanish and Italian (and Czech and Brazilian Portuguese in the menus). +speaks English, Japanese, Traditional and Simplified Chinese, Spanish, Italian and Russian (and Czech and Brazilian Portuguese in the menus). The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index 07cc3959..f76011a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,7 +101,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish and Italian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). @@ -139,7 +139,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), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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). -- **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) and Italian (every `it-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish and Italian 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 and Italian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7f896d18..7226e6c9 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -63,8 +63,20 @@ fn plural_czech(n: u64) -> usize { } } +/// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`. +fn plural_russian(n: u64) -> usize { + match n % 100 { + 11..=14 => 2, + _ => match n % 10 { + 1 => 0, + 2..=4 => 1, + _ => 2, + }, + } +} + /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 8] = [ +pub static LANGUAGES: [LangInfo; 9] = [ 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`). @@ -91,6 +103,11 @@ pub static LANGUAGES: [LangInfo; 8] = [ complete_menus: true, catalog: OnceLock::new(), }, + // Russian: the whole interface and the status and error messages, keeping the same names in + // English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here. + LangInfo { + code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() + }, // Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every // `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS` // strings (product name, language names, workspace names, perspective grid presets). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv new file mode 100644 index 00000000..a3853014 --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -0,0 +1,3598 @@ +# Russian (ru) catalog. Same format as zh-hant.tsv: context source translation +# (empty context = a plain UI string; `@id` = a command id; `@plural` = "one|other" forms). +# +# Clean-room: written from the meaning of VectorCraft's English labels in neutral, international +# Russian, using the feature names Russian-speaking users of vector illustration software know +# (Монтажная область, Образцы, Обводка, Заливка, Обработка контуров, Узор, Набор, Опорная точка, +# Перо, Прямое выделение). Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). +# +# 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 +# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). +# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the +# leading/trailing spaces of fragments exactly as they are. + + — missing — отсутствует + to search every command для поиска любой команды + (closest) (ближайший) + + Add Scale + Добавить масштаб + 0.2 (quality) to 100 (speed) 0.2 (качество) — 100 (скорость) + 1 Color Job… Задание с 1 цветом… + 1 {unit} on the artboard stands for {real} {unit} in the scene. 1 {unit} на монтажной области соответствует {real} {unit} в сцене. + 2 Color Job… Задание с 2 цветами… + 3 Color Job… Задание с 3 цветами… + 30° Angles Углы 30° + 3D and Materials 3D и материалы + 3D Ribbon 3D-лента + 45° Angles Углы 45° + 60° Angles Углы 60° + 90° & 45° & 30° Angles Углы 90°, 45° и 30° + 90° & 45° Angles Углы 90° и 45° + 90° Angles Углы 90° + < 1 sec < 1 с + <Непрозрачная маска> + [Default] is protected: New… starts an editable copy. [По умолчанию] защищён: «Создать…» создаёт редактируемую копию. + [Unnamed] [Без имени] + A File… Файл… + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. Меньшее значение точнее повторяет обводку; большее даёт меньше точек и более гладкие кривые. + A new preset starting from the selected one Новый набор на основе выбранного + A saved copy of the selected preset Сохранённая копия выбранного набора + About VectorCraft О программе VectorCraft + above сверху + Absolute Position Абсолютная позиция + Accurate Точный + Abutting Смежный + Action {n} Действие {n} + Actions Действия + Activate Document Активировать документ + Active Perspective Plane Активная плоскость перспективы + Actual Size Реальный размер + Add Добавить + Add Anchor (Pen) Добавить опорную точку (Перо) + Add Anchor Point Добавить опорную точку + Add Anchor Point Tool Инструмент «Добавить опорную точку» + Add Anchor Points Добавить опорные точки + Add Effect Добавить эффект + Add Freeform Line Добавить произвольную линию + Add Freeform Point Добавить произвольную точку + Add Guide Добавить направляющую + Add Mesh Line Добавить линию сетки + Add New Добавить новый + Add New Effect Добавить новый эффект + Add New Fill Добавить новую заливку + Add New Stroke Добавить новую обводку + Add Selected Artwork (Alt: as one asset) Добавить выделенный рисунок (Alt: как один ресурс) + Add Selected Artwork as Multiple Assets Добавить выделенный рисунок как несколько ресурсов + Add Selected Artwork as One Asset Добавить выделенный рисунок как один ресурс + Add Spine Point Добавить точку остова + Add Swatches Добавить образцы + Add to Graphic Styles Добавить в графические стили + Add to Profiles Добавить в профили + Add to Profiles… Добавить в профили… + Add to Selection Добавить к выделению + Add to Swatches Добавить в образцы + Add Used Colors Добавить использованные цвета + Additional Plug-ins Folder Папка дополнительных модулей + Adjust Adjoining Width Points Настроить смежные точки ширины + Adjust Color Balance… Настроить цветовой баланс… + Adjust Colors Настроить цвета + Adjust Options: Параметры настройки: + Advanced Дополнительно + Advanced Options Дополнительные параметры + Align Выровнять + Align center Выровнять по центру + Align left Выровнять по левому краю + Align Objects: Выравнивание объектов: + Align On: Выравнивать по: + Align right Выровнять по правому краю + Align Stroke to Center Выровнять обводку по центру + Align Stroke to Inside Выровнять обводку внутри + Align Stroke to Outside Выровнять обводку снаружи + Align Stroke: Выравнивание обводки: + Align to Artboard Выровнять по монтажной области + Align to Glyph Bounds Выровнять по границам глифа + Align to Key Object Выровнять по ключевому объекту + Align to Page Выровнять по странице + Align to Path Выровнять по контуру + Align to Selection Выровнять по выделению + Align To: Выравнивать по: + Align: Выравнивание: + Alignment Guides Направляющие выравнивания + Alignment: Выравнивание: + All Все + All Artwork Above Весь рисунок выше + All Caps Все прописные + All Categories Все категории + All colours are within the CMYK gamut Все цвета входят в цветовой охват CMYK + All on Active Artboard Всё на активной монтажной области + All on Same Layers Всё на тех же слоях + All printer's marks Все типографские метки + All Scripts Все письменности + All Text Objects Все текстовые объекты + All the art Весь рисунок + All Tools Все инструменты + Alt Alt + Alt: Alt: + Alter Paths for Appearance Изменять контуры для оформления + Amount Количество + Amount: Количество: + Anchor Point Опорная точка + Anchor Point Tool Инструмент «Опорная точка» + Anchor Points Опорные точки + Anchor Points, Handle, and Bounding Box Display Отображение опорных точек, маркеров и ограничительной рамки + Anchor/Path Labels Метки опорных точек/контуров + Anchors: Опорные точки: + Angle Угол + Angle: Угол: + Angles Углы + Animated Zoom Анимированное масштабирование + Anti-alias Сглаживание + Anti-Alias Сглаживание + Anti-alias Rasters Сглаживать растровые изображения + Anti-aliased Artwork Сглаженный рисунок + Anti-aliasing: Сглаживание: + Any Kind Любой тип + Appearance Оформление + Appearance Attribute Атрибут оформления + Appearance Item Элемент оформления + Appearance of Black Отображение чёрного + Appearance Panel Панель «Оформление» + Appearance panel Панель «Оформление» + Applies when the linked file is relinked or updated. Применяется при повторной привязке или обновлении связанного файла. + Applies the next time VectorCraft starts. Применяется при следующем запуске VectorCraft. + Apply Brush Применить кисть + Apply Character Style Применить стиль символа + Apply Effect Применить эффект + Apply Graphic Style Применить графический стиль + Apply Last Color Применить последний цвет + Apply Last Effect Применить последний эффект + Apply Last Gradient Применить последний градиент + Apply Paragraph Style Применить стиль абзаца + Apply SVG Filter… Применить фильтр SVG… + Apply to All Применить ко всем + Apply to All ({count} more missing) Применить ко всем (ещё {count} отсутствуют) + Arc Дуга + Arc Lower… Дуга снизу… + Arc Tool Инструмент «Дуга» + Arc Upper… Дуга сверху… + Arch… Арка… + Arc… Дуга… + Area / Path Type Текст в области / по контуру + Area Graph Tool Инструмент «Диаграмма с областями» + Area Text Objects Текстовые объекты в области + Area Type Options Параметры текста в области + Area Type Options… Параметры текста в области… + Area Type Tool Инструмент «Текст в области» + Around Object Вокруг объекта + Arrange Монтаж + Arrange: Bring to Front Монтаж: на передний план + Arrowheads: Стрелки: + Art Рисунок + Art using a deleted global swatch keeps its colour. Рисунок с удалённым глобальным образцом сохраняет свой цвет. + Artboard Options Параметры монтажной области + Artboard Options: edit with the Artboard tool Параметры монтажной области: редактируются инструментом «Монтажная область» + Artboard Options… Параметры монтажной области… + Artboard Tool Инструмент «Монтажная область» + Artboard {number} Монтажная область {number} + Artboard: Монтажная область: + Artboards Монтажные области + ArtCraft Website Веб-сайт ArtCraft + ArtCraft website Веб-сайт ArtCraft + Artwork Colors Цвета рисунка + As Multiple Assets Как несколько ресурсов + As Single Asset Как один ресурс + Ascender Выносной элемент вверх + Ask When Modified Спрашивать при изменении + Aspect Ratio Соотношение сторон + Asset Export Экспорт ресурсов + Asset Export Settings Параметры экспорта ресурсов + Assets Ресурсы + Assign Назначить + Assign Profile… Назначить профиль… + Assign: Назначить: + Attach to Active Plane Прикрепить к активной плоскости + Attributes Атрибуты + Author Title: Должность автора: + Author: Автор: + Auto Авто + Automatic Автоматически + Auto Size New Area Type Автоматически подгонять размер нового текста в области + Auto Spell Check Автоматическая проверка орфографии + Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей + Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области) + Automatic flatness Автоматическая плоскостность + Automatically Автоматически + Automatically Save Recovery Data Автоматически сохранять данные восстановления + Average Усреднить + Average… Усреднить… + Axis: Ось: + Background Color: Цвет фона: + Background Contents Содержимое фона + Background Contents: Содержимое фона: + Background: Фон: + Backspace Backspace + Bar Graph Tool Инструмент «Гистограмма» + Baseline Базовая линия + Baseline Shift Смещение базовой линии + Basic Latin Основная латиница + Batch Пакет + Begin Recording Начать запись + Bend: Изгиб: + Bevel Join Скос соединения + Bitmap Растровое изображение + Black Чёрный + Black and White Чёрно-белый + Black and White Logo Чёрно-белый логотип + BLANK DOCUMENT PRESETS НАБОРЫ ПУСТЫХ ДОКУМЕНТОВ + Bleed Вылет + Bleed: Вылет: + Bleeds Вылеты + Blend Переход + Blend Front to Back Переход спереди назад + Blend Horizontally Переход по горизонтали + Blend Info Информация о переходе + Blend Options Параметры перехода + Blend Options… Параметры перехода… + Blend Tool Инструмент «Переход» + Blend Vertically Переход по вертикали + Blending Mode Режим наложения + Blends and Transparency Переходы и прозрачность + Bloat Tool Инструмент «Раздувание» + Blob Brush Кисть-клякса + Blob Brush Tool Инструмент «Кисть-клякса» + Blue Синий + Blur Размытие + Bottom Внизу + Bottom in Front Нижний спереди + Bottom: Внизу: + Bounds Границы + Bounds (Fill Proportionally) Границы (заполнять пропорционально) + Bounds (Fit Proportionally) Границы (вписывать пропорционально) + Break Link to Graphic Style Разорвать связь с графическим стилем + Break Link to Symbol Разорвать связь с символом + Brick by Column Кирпич по столбцам + Brick by Row Кирпич по строкам + Brick Offset: Смещение кирпича: + Brightness Яркость + Brightness/Contrast… Яркость/контраст… + Brights Яркие + Bring Forward На один уровень вперёд + Bring to Front На передний план + Bristle Щетина + Bristle Brush Strokes Мазки щетинной кисти + Browser Браузер + Brush Affects: Кисть влияет на: + Brush Definition Определение кисти + Brush Libraries Библиотеки кистей + Brush Libraries (on the roadmap) Библиотеки кистей (в плане развития) + Brush Options… Параметры кисти… + Brush Strokes Мазки кисти + Brushes Кисти + Built-in Libraries Встроенные библиотеки + Built-in presets are read-only: New… starts an editable copy. Встроенные наборы доступны только для чтения: «Создать…» создаёт редактируемую копию. + Built-in presets don't change: New makes a copy you can edit. Встроенные наборы не изменяются: «Создать» делает копию, которую можно редактировать. + Built-in presets don't change: New… starts an editable copy. Встроенные наборы не изменяются: «Создать…» создаёт редактируемую копию. + Bulge… Выпуклость… + Bullet Маркер + Butt Cap Стыковое окончание + Byte Order: Порядок байтов: + Calligraphic Каллиграфическая + Can't print these settings: see the Summary Невозможно напечатать с этими параметрами: см. сводку + Cancel Отмена + Cancel Key Object Отменить ключевой объект + Canvas Color Цвет холста + Cap Окончание + Cap: Окончание: + Cascade Каскад + Center Центр + Center Artwork Отцентрировать рисунок + Center on Artboard Отцентрировать на монтажной области + Center-Justified Tab Табуляция с выравниванием по центру + Centimeters Сантиметры + Change Изменить + Change All Изменить все + Change Case Изменить регистр + Change Screen Mode (F) Сменить режим экрана (F) + Change to Global Rulers Переключить на глобальные линейки + Change to Right-to-Left Layout Переключить на макет справа налево + Changed since the last Refresh. Изменено с момента последнего обновления. + Changes allowed: Разрешённые изменения: + Changes made since it was last saved will be lost. Изменения, сделанные после последнего сохранения, будут потеряны. + Changing these settings changes how raster effects look when exported, flattened or expanded. Изменение этих параметров влияет на вид растровых эффектов при экспорте, объединении или разборке. + Channel Канал + Character Символ + Character Alignment Выравнивание символов + Character / Paragraph Символ / Абзац + Character Rotation Поворот символов + Character Set: Набор символов: + Character Style Стиль символа + Character Style Options Параметры стиля символа + Character Styles Стили символов + Check Links Проверить связи + Check Spelling… Проверить орфографию… + Choose a folder Выберите папку + Choose… Выбрать… + Circle Круг + Classic Defaults Классические значения по умолчанию + Clean Up… Очистить… + Clear Очистить + Clear All Tabs Удалить все табуляции + Clear Appearance Очистить оформление + Clear Guides Удалить направляющие + Clear Overrides Очистить переопределения + Clear Recent Files Очистить список последних файлов + Click Щелчок + Click an anchor point Щёлкните опорную точку + Click or drag Щёлкните или перетащите + Click the art with the Gradient tool to add or select points. Щёлкните рисунок инструментом «Градиент», чтобы добавить или выбрать точки. + Click the first point Щёлкните первую точку + Click the ramp or a type button to apply a gradient. Щёлкните шкалу или кнопку типа, чтобы применить градиент. + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Щёлкните, чтобы добавить табуляцию, перетащите для перемещения, перетащите за пределы линейки для удаления + Click to choose a swatch, Shift-click to mix a colour Щёлкните, чтобы выбрать образец, Shift+щелчок — чтобы смешать цвет + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Щёлкните, чтобы редактировать маску; Alt+щелчок — чтобы показать только маску; Shift+щелчок — чтобы отключить или включить её + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Щёлкните, чтобы разместить каждый файл на 100 %, или перетащите для изменения размера; стрелки переключают файлы, Esc пропускает один. + Click to select, double-click to edit Щёлкните, чтобы выделить, дважды щёлкните, чтобы редактировать + Click to toggle visibility Щёлкните, чтобы показать или скрыть + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Щёлкните, чтобы увеличить, Alt+щелчок — чтобы уменьшить, перетащите для панорамирования, дважды щёлкните, чтобы вписать + Client-side (.html) Клиентская часть (.html) + Clip Обрезать + Clip Complex Regions Обрезать сложные области + Clip to Art Обрезать по рисунку + Clip to Artboard Обрезать по монтажной области + Clip to Bounding Box Обрезать по ограничительной рамке + Clipboard as PDF Буфер обмена как PDF + Clipboard as PNG Буфер обмена как PNG + Clipboard as SVG Буфер обмена как SVG + Clipboard as Text Буфер обмена как текст + Clipboard Formats Форматы буфера обмена + Clipboard Handling Работа с буфером обмена + Clipping Mask Маска обрезки + Clipping Masks Маски обрезки + Close Закрыть + Close All Закрыть всё + Close Library Закрыть библиотеку + Close Path Замкнуть контур + Close paths when ends are within: Замыкать контуры, если концы ближе: + Cmd Cmd + CMYK CMYK + CMYK Color Цвет CMYK + CMYK colours print with their own values; off, they go through the colour settings too. Цвета CMYK печатаются со своими значениями; если выключено, они также проходят через настройки цвета. + Collapse Panels to Icons Свернуть панели до значков + Collapse to Icons Свернуть до значков + Collate Разобрать по копиям + Collect for Export Собрать для экспорта + Collect in New Layer Собрать в новый слой + Collect Links in a Separate Folder Собрать связи в отдельную папку + Color Цвет + Color (,) Цвет (,) + Color Adjustments Настройки цвета + Color bars Цветные полосы + Color blindness – Deuteranopia-type Дальтонизм — дейтеранопия + Color blindness – Protanopia-type Дальтонизм — протанопия + Color Burn Затемнение основы + Color conversion: Преобразование цвета: + Color Dodge Осветление основы + Color Group Цветовая группа + Color Group: Цветовая группа: + Color Group: {name} Цветовая группа: {name} + Color Guide Цветовой гид + Color Guide Options Параметры цветового гида + Color Guide Options… Параметры цветового гида… + Color images Цветные изображения + Color Library Библиотека цветов + Color Library… Библиотека цветов… + Color Management Управление цветом + Color Mixer (Shift-click the chip) Смеситель цветов (Shift+щелчок по образцу) + Color Mode Цветовой режим + Color Mode: Цветовой режим: + Color Model: Цветовая модель: + Color Models Цветовые модели + Color Picker Палитра цветов + Color Picker… Палитра цветов… + Color Reduction: Сокращение числа цветов: + Color Settings Настройки цвета + Color Settings… Настройки цвета… + Color Space Цветовое пространство + Color Swatches Цветовые образцы + Color Table Цветовая таблица + Color Table ({n} color) Цветовая таблица ({n} цвет) + Color Table ({n} colors) Цветовая таблица ({n} цветов) + Color Theme Цветовая тема + Color Themes Цветовые темы + Color Type: Тип цвета: + Color: Цвет: + Colorize Тонировать + Colors Цвета + Colors: Цвета: + Column Graph Tool Инструмент «Гистограмма» + Columns: Столбцы: + Column… Столбец… + Combine Slices Объединить фрагменты + Coming soon — tracked in the parity plan Скоро — отслеживается в плане развития + Command Click to Select Objects Behind Cmd+щелчок для выбора объектов позади + Command Dialog Диалоговое окно команды + Community Сообщество + Compatibility: Совместимость: + 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. + Compound Path Составной контур + Compress (RLE) Сжать (RLE) + Compress text and line art Сжимать текст и штриховой рисунок + Compression Сжатие + Compression › Color › Above Ppi Сжатие › Цвет › Выше ppi + Compression: Сжатие: + Condition identifier: Идентификатор условия: + Conflict {n} of {count} Конфликт {n} из {count} + Connect Selected End Points Соединить выбранные конечные точки + Consolidate All Windows Объединить все окна + Constrain Angle Ограничить угол + Constrain Width and Height Proportions Сохранять пропорции ширины и высоты + Construction Guides Направляющие построения + Contextual Alternates Контекстные альтернативы + Contextual Task Bar Контекстная панель задач + Pin Bar Position Закрепить положение панели + Reset Bar Position Сбросить положение панели + Contrast Контраст + Control Control + Convert Преобразовать + Convert All Spot Colors to Process Преобразовать все плашечные цвета в составные + Convert All Strokes to Outlines Преобразовать все обводки в контуры + Convert All Text to Outlines Преобразовать весь текст в контуры + Convert Anchor Point Преобразовать опорную точку + Convert Anchor Points Преобразовать опорные точки + Convert Color Преобразовать цвет + Convert Document Color Mode Преобразовать цветовой режим документа + Convert Process to Global Преобразовать составной в глобальный + Convert Selected Anchor Points to Corner Преобразовать выбранные опорные точки в угловые + Convert Selected Anchor Points to Smooth Преобразовать выбранные опорные точки в сглаженные + Convert text to outlines Преобразовать текст в контуры + Convert To Area Type Преобразовать в текст в области + Convert to Artboards Преобразовать в монтажные области + Convert to CMYK Преобразовать в CMYK + Convert to Grayscale Преобразовать в оттенки серого + Convert To Outlines Преобразовать в контуры + Convert To Point Type Преобразовать в точечный текст + Convert to RGB Преобразовать в RGB + Convert to Shape Преобразовать в фигуру + Convert to sRGB Преобразовать в sRGB + Convert: Преобразовать: + Copied the CSS of {rules} object(s) Скопирован CSS для {rules} объекта(ов) + Copies: Копии: + Copy Копировать + Copy (make a transformed copy) Копировать (создать преобразованную копию) + Copy All Objects Копировать все объекты + Copy All Styles Копировать все стили + Copy Color Value (Hex) Копировать значение цвета (hex) + Copy Fonts Used in the Document Копировать шрифты, использованные в документе + Copy Links Копировать связи + Copy Selected Style Копировать выбранный стиль + Copy Width Point Копировать точку ширины + Copyright Copyright + Copyright Info URL: URL информации об авторских правах: + Copyright Notice: Уведомление об авторских правах: + Copyright Status: Статус авторских прав: + Copyright Symbol Символ авторского права + Corner Radius Радиус угла + Corner Radius: Радиус угла: + Corner: Угол: + Corners Углы + Chamfer Фаска + Corners… Углы… + Cut Path Разрезать контур + Descender Выносной элемент вниз + Flip Отразить + Inverted Round Инвертированный скруглённый + Radius: Радиус: + Round Скруглённый + Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e} + Couldn't paste from the clipboard: {e} Не удалось вставить из буфера обмена: {e} + Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e} + Couldn't {} {name}: {e} Не удалось {} {name}: {e} + Create Создать + Create Clipping Mask Создать маску обрезки + Create Document Создать документ + Create from Guides Создать из направляющих + Create from Selection Создать из выделения + Create From: Создать из: + Create Gradient Mesh… Создать градиентную сетку… + Create Graph Создать диаграмму + Create New Layer Создать новый слой + Create New Sublayer Создать новый подcлой + Create New Swatch… Создать новый образец… + Create New {kind} Style Создать новый стиль {kind} + Create Object Mosaic… Создать мозаику объектов… + Create Outlines Создать контуры + Create Path Создать контур + Create PDF layers from top-level layers Создавать слои PDF из слоёв верхнего уровня + Create PDF-Compatible File Создать файл, совместимый с PDF + Create Report Создать отчёт + Create Slice Создать фрагмент + Create Sub-folders Создать вложенные папки + Create Text Создать текст + Create Trim Marks Создать метки подрезки + Created Создано + Created: Создано: + Crop Обрезать + Crop Image Обрезать изображение + Mask Маска + Crop Marks Метки обрезки + Crop To Обрезать до + Crystallize Tool Инструмент «Кристаллизация» + CSS Properties Свойства CSS + Ctrl Ctrl + Currency & Symbols Валюты и символы + Current Brush Текущая кисть + Current Colors ({total}) Текущие цвета ({total}) + Current Symbol Текущий символ + Curvature Кривизна + Curvature Tool Инструмент «Кривизна» + Curves… Кривые… + Custom Пользовательский + Customize… Настроить… + Cut Вырезать + Cut Path at Selected Anchor Points Разрезать контур в выбранных опорных точках + Cyan Голубой + Dark Тёмный + Darken Замена тёмным + dash штрих + Dash Pattern Шаблон штрихов + Dashed Штриховой + Dashed Line Штриховая линия + Data Recovery Восстановление данных + Data… Данные… + Dates Даты + Decimal-Justified Tab Десятичная табуляция + Decimal: Десятичный разделитель: + Decorative Декоративный + Default По умолчанию + Default Actions Действия по умолчанию + Default Figure Цифры по умолчанию + Default Fill and Stroke Заливка и обводка по умолчанию + Default Fill and Stroke (D) Заливка и обводка по умолчанию (D) + Default Language Язык по умолчанию + Default Printer Принтер по умолчанию + Default Swatches Образцы по умолчанию + Define Grid… Определить сетку… + Define Perspective Grid Определить сетку перспективы + Define Perspective Grid… Определить сетку перспективы… + Delete Удалить + Delete All Удалить всё + Delete Anchor Point Удалить опорную точку + Delete Anchor Point Tool Инструмент «Удалить опорную точку» + Delete Anchor Points Удалить опорные точки + Delete Artboard Удалить монтажную область + Delete Artboards Удалить монтажные области + Delete Brush Удалить кисть + Delete Character Style Удалить стиль символа + Delete Empty Artboards Удалить пустые монтажные области + Delete Freeform Point Удалить произвольную точку + Delete Graphic Style Удалить графический стиль + Delete Graphic Style (Alt-click: without asking) Удалить графический стиль (Alt+щелчок: без запроса) + Delete Layer Удалить слой + Delete Mesh Point Удалить точку сетки + Delete Paragraph Style Удалить стиль абзаца + Delete Pattern Удалить узор + Delete PDF Preset Удалить набор PDF + Delete Perspective Grid Preset Удалить набор сетки перспективы + Delete Point Удалить точку + Delete Preset Удалить набор + Delete Print Preset Удалить набор печати + Delete Profile Удалить профиль + Delete Save for Web Preset Удалить набор «Сохранить для web» + Delete Selected Item Удалить выбранный элемент + Delete Selection Удалить выделение + Delete Slices Удалить фрагменты + Delete Spine Point Удалить точку остова + Delete Stop Удалить точку перехода + Delete Swatch Удалить образец + Delete Symbol Удалить символ + Delete Tab Удалить табуляцию + Delete the graphic style “{name}”? Удалить графический стиль «{name}»? + Delete Theme Удалить тему + Delete theme Удалить тему + Delete these {count} swatches and groups? Удалить эти образцы и группы ({count})? + Delete these {n} graphic styles? Удалить эти графические стили ({n})? + Delete this saved preset Удалить этот сохранённый набор + Delete this {n} graphic style? Удалить этот графический стиль ({n})? + Delete Transparency Flattener Preset Удалить набор объединения прозрачностей + Delete Width Point Удалить точку ширины + Delete Workspace Удалить рабочую среду + Delete {kind} Style Удалить стиль {kind} + Delete “{name}”? Удалить «{name}»? + Depth: Глубина: + Description Описание + Description: Описание: + Deselect Отменить выделение + Design… Дизайн… + Destination: Назначение: + Detail: Детализация: + Devices Устройства + Dialog Диалоговое окно + Diamond Ромб + Difference Разница + Differences from {preset}: Отличия от {preset}: + Dim Copies to: Приглушить копии до: + Dim Images to: Приглушить изображения до: + Dimensions Размеры + Direct Selection Tool Инструмент «Прямое выделение» + Direction Handles Маркеры направления + Disable Auto Add/Delete Отключить авто-добавление/удаление + Disable Opacity Mask Отключить непрозрачную маску + Discard Отменить + Discard Empty Type Отменить пустой текст + Discard Recovered Documents Отменить восстановленные документы + Discard White Overprint in Output Отменить наложение белого при выводе + Discarded {} recovered document(s) Отменено восстановленных документов: {} + Discord Discord + Discretionary Hyphen Мягкий перенос + Discretionary Ligatures Дискреционные лигатуры + Display All Blacks Accurately Отображать все чёрные точно + Display All Blacks as Rich Black Отображать все чёрные как составной чёрный + Display Bitmaps as Anti-aliased Images in Pixel Preview Отображать растровые изображения со сглаживанием в режиме предварительного просмотра пикселей + Display Options Параметры отображения + Display Print Size at 100% Zoom Отображать размер печати при масштабе 100 % + Distort & Transform Деформация и трансформирование + Distort Appearance Исказить оформление + Distort Linear Gradients Исказить линейные градиенты + Distort Pattern Fills Исказить заливки узором + Distortion Искажение + Distribute Распределить + Distribute Objects: Распределение объектов: + Distribute Spacing Распределить интервалы + Distribute Spacing: Распределение интервалов: + Dither: Дизеринг: + Divide Разделить + Divide Horizontally Into Разделить по горизонтали на + Divide Objects Below Разделить объекты ниже + Divide Slices Разделить фрагменты + Divide Slices… Разделить фрагменты… + Divide Vertically Into Разделить по вертикали на + Do Not Move Не перемещать + Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»? + Document Документ + Document Color Mode Цветовой режим документа + Document Info Сведения о документе + Document Ink Options Параметры красок документа + Document JSON JSON документа + Document open password Пароль для открытия документа + Document profile Профиль документа + Document Raster Effects Settings Параметры растровых эффектов документа + Document Raster Effects Settings Dialog Диалоговое окно «Параметры растровых эффектов документа» + Document Raster Effects Settings… Параметры растровых эффектов документа… + Document Setup Параметры документа + Document Setup… Параметры документа… + Document Swatches Образцы документа + Document Title: Название документа: + Documents you create appear here. Созданные вами документы появятся здесь. + Don't Save Не сохранять + Don't Show Center Не показывать центр + Done Готово + Dots Точки + Double Click To Isolate Двойной щелчок для изоляции + Double Left Quotation Marks Двойные открывающие кавычки + Double Quotes: Двойные кавычки: + Double Right Quotation Marks Двойные закрывающие кавычки + Double-click Двойной щелчок + Double-click to edit the characters' paint Дважды щёлкните, чтобы редактировать заливку символов + Double-click to insert Дважды щёлкните для вставки + Double-click to rename Дважды щёлкните для переименования + Double-click to target the contents Дважды щёлкните, чтобы выбрать содержимое как объект + Download Скачать + Download: Скачивание: + Downloaded {what} Скачано: {what} + Downloaded {} file(s) Скачано файлов: {} + Drag Перетащить + Drag a handle Перетащите маркер + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Перетащите рисунок сюда или выделите его и нажмите +. Удерживайте Alt для одного ресурса из нескольких объектов. + Drag onto art to apply this appearance Перетащите на рисунок, чтобы применить это оформление + Drag the art to move it on the paper Перетащите рисунок, чтобы переместить его на листе + Draw Нарисовать + Draw Behind Рисовать позади + Draw in Perspective Рисовать в перспективе + Draw Inside Рисовать внутри + Draw Normal Обычный рисунок + Draw: Рисовать: + Drawing Mode Режим рисования + Drop Shadow Тень + Drop Shadow… Тень… + Duotone Gradients Двухтоновые градиенты + Duplicate Дублировать + Duplicate and Offset 10 pt Дублировать и сместить на 10 пт + Duplicate Artboard Дублировать монтажную область + Copy Artboard Копировать монтажную область + Cut Artboard Вырезать монтажную область + Duplicate Artboards Дублировать монтажные области + Duplicate Brush Дублировать кисть + Duplicate Character Style Дублировать стиль символа + Duplicate Effect Дублировать эффект + Duplicate Graphic Style Дублировать графический стиль + Duplicate Item Дублировать элемент + Duplicate Layer Дублировать слой + Duplicate Paragraph Style Дублировать стиль абзаца + Duplicate Selected Item Дублировать выбранный элемент + Duplicate Selection Дублировать выделение + Duplicate Slice Дублировать фрагмент + Duplicate Swatch Дублировать образец + Duplicate Symbol Дублировать символ + Duplicate {kind} Style Дублировать стиль {kind} + Dutch Нидерландский + DXF Import Options Параметры импорта DXF + DXF Info Сведения о DXF + DXF Options Параметры DXF + DXF Options Dialog Диалоговое окно «Параметры DXF» + e.g. 1-3, 5 напр. 1-3, 5 + Each page prints as one image, at the document's raster effects resolution. Каждая страница печатается как одно изображение с разрешением растровых эффектов документа. + Earth Tones Земляные тона + East Asian Type Восточноазиатский текст + Edit Редактирование + Edit Artboards Редактировать монтажные области + Edit Clipping Path Редактировать контур обрезки + Edit Color Group Редактировать цветовую группу + Edit Colors Редактировать цвета + Edit Contents Редактировать содержимое + Edit Custom Dictionary… Редактировать пользовательский словарь… + Edit Envelope Редактировать оболочку + Edit Freeform Point Редактировать произвольную точку + Edit Gradient Редактировать градиент + Edit Opacity Mask Редактировать непрозрачную маску + Edit or Apply Color Group Редактировать или применить цветовую группу + Edit or Apply Color Group… Редактировать или применить цветовую группу… + Edit or Apply Colors Редактировать или применить цвета + Edit Original Редактировать оригинал + Edit Pattern Редактировать узор + Edit PDF Preset Редактировать набор PDF + Edit Print Preset Редактировать набор печати + Edit selected paths within: Редактировать выбранные контуры в пределах: + Edit Selection Редактировать выделение + Edit Selection… Редактировать выделение… + Edit Symbol Редактировать символ + Edit Text Редактировать текст + Edit the applied effect, or add another one? Редактировать применённый эффект или добавить другой? + Edit the selected point's colour in the Color panel Редактируйте цвет выбранной точки на панели «Цвет» + Edit the stop Редактировать точку перехода + Edit Theme Редактировать тему + Edit theme Редактировать тему + Edit Toolbar Редактировать панель инструментов + Edit Views Редактировать виды + Edit Views… Редактировать виды… + Edit… Редактировать… + Effect Эффект + Effect Options Параметры эффекта + Effective PPI Эффективные ppi + Effects Эффекты + Effect… Эффект… + Ellipse Эллипс + Ellipse Tool Инструмент «Эллипс» + Ellipse… Эллипс… + Ellipsis Многоточие + Em Box Bottom/Left Низ/лево кегельной площадки + Em Box Center Центр кегельной площадки + Em Box Top/Right Верх/право кегельной площадки + 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 page thumbnails Внедрить миниатюры страниц + Embedded Внедрено + Embedded Image Внедрённое изображение + Embedded {n} image(s) Внедрено изображений: {n} + Embedded {n} image(s); {missing} missing: relink them first Внедрено изображений: {n}; отсутствует {missing}: сначала восстановите связи + Emulsion: Эмульсия: + En Dash Среднее тире + En Space Полукруглая шпация + Enable Background Export Включить фоновый экспорт + Enable Background Save Включить фоновое сохранение + Enable Contextual Task Bar Включить контекстную панель задач + Enable copying of text, images and other content Разрешить копирование текста, изображений и другого содержимого + Enable in-menu font previews Включить предварительный просмотр шрифтов в меню + Enable Missing Glyph Protection Включить защиту отсутствующих глифов + Enable Opacity Mask Включить непрозрачную маску + Enable plaintext metadata Включить метаданные в виде простого текста + Enable Rubber Band for Включить «резиновую полосу» для + Enable text access for screen readers Разрешить доступ к тексту для программ чтения с экрана + Enable Touch Gestures Включить сенсорные жесты + Enable Touch Workspace Включить сенсорную рабочую среду + Encoding Кодировка + Encoding: Кодировка: + Encryption: {name} (set by Compatibility) Шифрование: {name} (задаётся параметром «Совместимость») + End arrowhead Конечная стрелка + English English + English: UK Английский: Великобритания + English: USA Английский: США + Enter Enter + Enter Isolation Mode Войти в режим изоляции + Entire Font Весь шрифт + Envelope Distort Деформация оболочкой + Envelope Info Сведения об оболочке + Envelope Mesh Сетка оболочки + Envelope Options Параметры оболочки + Envelope Options… Параметры оболочки… + EPS Options Параметры EPS + EPS Options Dialog Диалоговое окно «Параметры EPS» + Eraser Ластик + Eraser Tool Инструмент «Ластик» + Error Ошибка + Esc Esc + Every (minutes) Каждые (минут) + Every option is at its default. Все параметры имеют значения по умолчанию. + Every option matches {base}. Все параметры совпадают с {base}. + Every-line Composer Компоновщик всех строк + Exact dash lengths Точная длина штрихов + Exceptions (comma separated) Исключения (через запятую) + Exclude Исключить + Exclusion Исключение + Exit Isolation Mode Выйти из режима изоляции + Expand Разобрать + Expand Appearance Разобрать оформление + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap «Разобрать» применяется к составным фигурам (Alt+щелчок по режиму фигуры) — в плане развития + Expand Brush Strokes Разобрать мазки кисти + Expand Compound Shape Разобрать составную фигуру + Expand Gradient Mesh Разобрать градиентную сетку + Expand Gradient To Разобрать градиент до + Expand Layers Panel Rows Развернуть строки панели «Слои» + Expand Options Параметры разбора + Expand Panels Развернуть панели + Expand Repeat Разобрать повтор + Expand Shape Разобрать фигуру + Expand: Разобрать: + Expand… Разобрать… + Export Экспорт + Export All… Экспортировать всё… + Export and Clipboard Transparency Flattener Settings Параметры объединения прозрачностей для экспорта и буфера обмена + Export Artboard Экспортировать монтажную область + Export As Экспортировать как + Export As PNG… Экспортировать как PNG… + Export As SVG… Экспортировать как SVG… + Export Asset Экспортировать ресурс + Export Assets Экспортировать ресурсы + Export As… Экспортировать как… + Export CSS Экспортировать CSS + Export CSS… Экспортировать CSS… + Export Document Экспортировать документ + Export DXF Экспортировать DXF + Export EPS Экспортировать EPS + Export for Screens Экспортировать для экранов + Export for Screens Settings Параметры экспорта для экранов + Export for Screens… Экспортировать для экранов… + Export Keyboard Shortcuts… Экспортировать сочетания клавиш… + Export Option Параметр экспорта + Export PDF Экспортировать PDF + Export PDF Presets Экспортировать наборы PDF + Export Perspective Grid Presets Экспортировать наборы сетки перспективы + Export Print Presets Экспортировать наборы печати + Export Selected Art Only Экспортировать только выделенный рисунок + Export Selected CSS… Экспортировать выделенный CSS… + Export Selection… Экспортировать выделение… + Export Settings Параметры экспорта + Export the selected assets (else all of them) Экспортировать выделенные ресурсы (иначе все) + Export to: Экспортировать в: + Export Transparency Flattener Presets Экспортировать наборы объединения прозрачностей + Export {count} Selected Экспортировать выбранные ({count}) + Export: Экспорт: + Exported {count} file(s) to {folder} Экспортировано файлов: {count} в {folder} + Exported {path} Экспортировано: {path} + Exported {what} Экспортировано: {what} + Exported {what} with {} note(s): {first} Экспортировано: {what} с примечаниями ({}) : {first} + Exporting {} Экспорт {} + Export… Экспорт… + Extrude & Bevel… Выдавливание и фаска… + Eyedropper Пипетка + Eyedropper Applies: Пипетка применяет: + Eyedropper Options Параметры пипетки + Eyedropper Picks Up: Пипетка берёт: + Eyedropper Tool Инструмент «Пипетка» + Eyedropper: click the art to sample the stop's colour Пипетка: щёлкните рисунок, чтобы взять цвет точки перехода + Favorites Избранное + Feather… Растушёвка… + Feet Футы + Feet & Inches Футы и дюймы + Fewer Elements Меньше элементов + Fidelity: Точность: + Figure Space Цифровая шпация + Figure: Цифры: + File Файл + File Dimensions Размеры файла + File Format: Формат файла: + File Formats Форматы файлов + File Handling Работа с файлами + File Info Сведения о файле + File Info Dialog Диалоговое окно «Сведения о файле» + File Info… Сведения о файле… + File Name: Имя файла: + File › Place… brings in an image, linked or embedded. Файл › Поместить… вставляет изображение, связанное или внедрённое. + Files Файлы + Files of a PDF standard can't be password-protected. Файлы стандарта PDF не могут быть защищены паролем. + Files of this standard tag their colours with ICC profiles. Файлы этого стандарта помечают свои цвета профилями ICC. + Fill Заливка + Fill & Stroke Заливка и обводка + Fill (X), double-click for the Color Picker Заливка (X), дважды щёлкните для палитры цветов + Fill and Stroke Заливка и обводка + Fill new strokes Заполнять новые обводки + Fill Color Цвет заливки + Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем + Fill Rule Правило заливки + Fill with Placeholder Text Заполнить текстом-заполнителем + Fill: Заливка: + Fill: click for swatches, Shift-click for the color mixer Заливка: щёлкните для образцов, Shift+щелчок — для смесителя цветов + Find Найти + Find and Replace… Найти и заменить… + Find Font Найти шрифт + Find Font… Найти шрифт… + Find Next Найти далее + Find Objects Найти объекты + First Page Первая страница + First-line Left Indent Отступ слева в первой строке + Fisheye… Рыбий глаз… + Fish… Рыба… + Fit All Вписать всё + Fit All in Window Вписать всё в окно + Fit Artboard in Window Вписать монтажную область в окно + Fit dashes to corners and ends Подгонять штрихи к углам и концам + Fit Headline Подогнать заголовок + Decrease Font Size Уменьшить размер шрифта + Increase Font Size Увеличить размер шрифта + Spacing Интервал + Step Type Подгонять текст по шагам + Fit in View Вписать в вид + Fit in Window Вписать в окно + Fit on Screen Вписать в экран + Fit to Artwork Bounds Вписать в границы рисунка + Fit to Selected Art Вписать в выделенный рисунок + Flag… Флаг… + Flare Блик + Flare Tool Инструмент «Блик» + Flare Tool Options Параметры инструмента «Блик» + Flat Image Плоское изображение + Flatness: Плоскостность: + Flatten Artwork Объединить рисунок + Flatten Transparency Объединить прозрачности + Flatten Transparency… Объединить прозрачности… + Flattener preset: Набор объединения: + Flattener presets Наборы объединения + Flattener Preview Предварительный просмотр объединения + Flip Across Отразить поперёк + Flip Along Отразить вдоль + Flip Along Horizontal Axis Отразить по горизонтальной оси + Flip Along Vertical Axis Отразить по вертикальной оси + Flip Horizontal Отразить по горизонтали + Flip Row Order Изменить порядок строк + Flip Vertical Отразить по вертикали + Float in Window Плавать в окне + Focal Fill Фокусная заливка + Focal Stroke Фокусная обводка + Folder Папка + Folder Name: Имя папки: + Font Шрифт + Font Family Семейство шрифтов + Font Family & Style Семейство и стиль шрифта + Font Family, Style & Size Семейство, стиль и размер шрифта + Font List Список шрифтов + Font Preview Size Размер предварительного просмотра шрифта + Font Size Размер шрифта + Font: Шрифт: + Fonts Шрифты + Fonts in Document Шрифты в документе + Fonts in Document: {count} Шрифты в документе: {count} + Fonts whose licence doesn't allow embedding are not copied. Шрифты, лицензия которых не допускает внедрение, не копируются. + Forced Line Break Принудительный разрыв строки + Format Формат + Format Options Параметры формата + Format Settings Параметры формата + Format: Формат: + Formats: Форматы: + Fractional Widths Дробные ширины + Fractions Дроби + Free Свободный + Free Distort Свободная деформация + Free Distort… Свободная деформация… + Free Transform Tool Инструмент «Свободное трансформирование» + Freeform Gradient Произвольный градиент + French Французский + Frequencies and angles are the output device's to apply. Частоты и углы применяются устройством вывода. + Frequency Частота + Full Document Весь документ + Full Screen Mode Полноэкранный режим + Full Screen Mode with Menu Bar Полноэкранный режим со строкой меню + Gamma Гамма + Gamut Check Проверка цветового охвата + Gamut Warning Предупреждение о цветовом охвате + gap зазор + Gap Options… Параметры зазоров… + Gaussian Blur… Размытие по Гауссу… + General Общие + General › Placement › X Общие › Размещение › X + General › Thumbnails Общие › Миниатюры + Generate CSS Создать CSS + Generate CSS: the whole document's CSS while nothing is selected Создать CSS: CSS всего документа, когда ничего не выделено + German Немецкий + Get Preferences Получить настройки + Get Text Range Получить диапазон текста + Global Глобальный + Global Brush Dimensions Глобальные размеры кисти + Global Process Color Глобальный составной цвет + Global Process Color, CMYK Глобальный составной цвет, CMYK + glyph {id} глиф {id} + Glyphs Глифы + Go to Artboard Перейти к монтажной области + Go To Link Перейти по ссылке + GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. + GPU Performance Производительность GPU + Gradient Градиент + Gradient (.) Градиент (.) + Gradient across stroke Градиент поперёк обводки + Gradient along stroke Градиент вдоль обводки + Gradient and Mesh Resolution: Разрешение градиентов и сеток: + Gradient Fill: click to apply, drag onto art Градиентная заливка: щёлкните для применения, перетащите на рисунок + Gradient Finishes Градиентные покрытия + Gradient Mesh Градиентная сетка + Gradient swatches Градиентные образцы + Gradient Tool Инструмент «Градиент» + Gradient Vector Вектор градиента + Gradient within stroke Градиент внутри обводки + Gradients Градиенты + Graph Диаграмма + Graph Data Данные диаграммы + Graph Type Тип диаграммы + Graphic Style Графический стиль + Graphic Style Libraries Библиотеки графических стилей + Graphic Style Libraries Menu Меню «Библиотеки графических стилей» + Graphic Style Library Библиотека графических стилей + Graphic Style Options Параметры графического стиля + Graphic Style Options… Параметры графического стиля… + Graphic Styles Графические стили + Graphic Styles Options Параметры графических стилей + Graphics Графика + Graphics Processor Графический процессор + Graphics: {adapter} Графика: {adapter} + Gravity Гравитация + Grays Серые + Grays and Neutrals Серые и нейтральные + Grayscale Оттенки серого + Grayscale images Изображения в оттенках серого + Greek & Cyrillic Греческий и кириллица + Green Зелёный + Grid Сетка + Grid Color & Opacity Цвет и непрозрачность сетки + Grid Colors: Цвета сетки: + Grid Size: Размер сетки: + Gridline every Линия сетки каждые + Gridline every: Линия сетки каждые: + Grids In Back Сетки на заднем плане + Group Сгруппировать + Group Selection Tool Инструмент «Выделение группы» + Guides Направляющие + Guides & Grid Направляющие и сетка + H Spacing: Интервал по горизонтали: + H: Ш: + Hair Space Волосяная шпация + Hand Tool Инструмент «Рука» + Hand-Drawn Нарисованный от руки + Handles Маркеры + Hard Light Жёсткий свет + Harmony Rules: Правила гармонии: + Harmony Sets Наборы гармонии + Height Высота + Height: Высота: + Help Справка + Help › Search Commands… Справка › Поиск команд… + Hex by Column Шестиугольник по столбцам + Hex by Row Шестиугольник по строкам + Hide Скрыть + Hide Artboards Скрыть монтажные области + Hide Bounding Box Скрыть ограничительную рамку + Hide Code Скрыть код + Hide Bar Скрыть панель + More Options Дополнительные параметры + Show Properties Panel Показать панель «Свойства» + Hide Corner Widget Скрыть угловой виджет + Hide Corner Widget for angles greater than Скрывать угловой виджет для углов больше + Hide Edges Скрыть края + Hide Gradient Annotator Скрыть аннотатор градиента + Hide Grid Скрыть сетку + Hide Guides Скрыть направляющие + Hide Hidden Characters Скрыть скрытые символы + Hide Note Скрыть примечание + Hide Options Скрыть параметры + Hide Others Скрыть остальные + Hide Print Tiling Скрыть разбиение для печати + Hide Rulers Скрыть линейки + Hide Slices Скрыть фрагменты + Hide Text Threads Скрыть связи текста + Hide Thumbnail Скрыть миниатюру + Hide Thumbnails Скрыть миниатюры + Hide Transparency Grid Скрыть сетку прозрачности + High Высокая + High (300 ppi) Высокая (300 ppi) + High Performance (discrete) Высокая производительность (дискретная) + Highlight Alternate Glyphs Выделять альтернативные глифы + Highlight anchors on mouse over Выделять опорные точки при наведении + Highlight Rows Выделять строки + Highlight Substituted Fonts Выделять заменённые шрифты + Highlight Substituted Glyphs Выделять заменённые глифы + History История + History Options… Параметры истории… + History States Состояния истории + Hollow Полый + Home Домой + Horizon Height: Высота горизонта: + Horizontal Горизонтально + Horizontal % По горизонтали, % + Horizontal Align Center Выровнять по центру по горизонтали + Horizontal Align Left Выровнять по левому краю по горизонтали + Horizontal Align Right Выровнять по правому краю по горизонтали + Horizontal Distribute Center Распределить по центру по горизонтали + Horizontal Distribute Left Распределить по левому краю по горизонтали + Horizontal Distribute Right Распределить по правому краю по горизонтали + Horizontal Distribute Space Распределить интервалы по горизонтали + Horizontal Grid: Горизонтальная сетка: + Horizontal Plane Горизонтальная плоскость + Horizontal Scale % Масштаб по горизонтали, % + Horizontal: По горизонтали: + How colours outside the press's gamut are separated. Как разделяются цвета за пределами цветового охвата печатной машины. + Hue Оттенок + Hue/Saturation… Оттенок/насыщенность… + Hyphenate Расставлять переносы + Hyphenation Расстановка переносов + Hyphenation… Расстановка переносов… + ICC Profile Профиль ICC + Identify Objects By Определять объекты по + Ignore Игнорировать + Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница) + Ignore White Игнорировать белый + Image Изображение + Image Info Сведения об изображении + Image Map: Карта изображения: + Image Size Размер изображения + Image Trace Трассировка изображения + Image Tracing Объект трассировки изображения + Tracing Presets Наборы трассировки + Image Trace Presets Наборы трассировки изображения + Image: Изображение: + Images: Изображения: + Import Keyboard Shortcuts… Импортировать сочетания клавиш… + Import PDF Импортировать PDF + Import PDF Presets Импортировать наборы PDF + Import Perspective Grid Presets Импортировать наборы сетки перспективы + Import Print Presets Импортировать наборы печати + Import Transparency Flattener Presets Импортировать наборы объединения прозрачностей + Imported {what}: {} Импортировано: {what}: {} + Import… Импорт… + In Tangent Handles Маркеры входящей касательной + In the selected objects В выделенных объектах + Inches Дюймы + Include Blacks with CMY: Включать чёрные с CMY: + Include Bleed Включать вылет + Include Cmy Blacks Включать чёрные CMY + Include CMYK PostScript in RGB Files Включать PostScript CMYK в файлы RGB + Include Document Thumbnails Включать миниатюры документа + Include Hidden Layers Включать скрытые слои + Include Linked Files Включать связанные файлы + Include Metadata Включать метаданные + Include non-printing layers Включать непечатаемые слои + Include SVG Code Включать код SVG + Include Swatches for Tints Включать образцы для оттенков + Inflate… Надувание… + Info Информация + Ink Краска + Inner Glow… Внутреннее свечение… + Input Black Входной чёрный + Input White Входной белый + Insert Break Character Вставить символ разрыва + Insert Character Вставить символ + Insert Special Character Вставить специальный символ + Insert Whitespace Character Вставить символ пробела + Inspect Document Проверить документ + Inspect Object Проверить объект + Install Plug-in Установить модуль + Install Plug-in… Установить модуль… + Installed plug-in {} Установлен модуль {} + Intensity: Интенсивность: + Intent: Назначение: + Interlaced Чересстрочный + Internal CSS Внутренний CSS + Internet Standard RGB (sRGB) Стандартный интернет-RGB (sRGB) + Intersect Пересечение + Inverse Обратный + Invert Инвертировать + Invert Colors Инвертировать цвета + Invert Mask Инвертировать маску + Isolate Aизолировать + Isolate Blending Изолировать наложение + Isolate Group Изолировать группу + Italian Итальянский + Japanese Японский + Join Соединить + Join Our Discord Присоединяйтесь к нашему Discord + Join our Discord Присоединяйтесь к нашему Discord + 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 Выключить с последней строкой по правому краю + Keep a link to an image file instead of only embedding it Сохранять связь с файлом изображения вместо простого внедрения + Keep both sides the same width Сохранять одинаковую ширину обеих сторон + Keep Formatting Сохранять форматирование + Keep Original Сохранить оригинал + Keep Plain Text Сохранять простой текст + Keep the pasted color as a new swatch with a numbered name. Сохранять вставленный цвет как новый образец с номерным именем. + Keep this row's colors (exclude) Сохранить цвета этой строки (исключить) + Keep: Сохранять: + Kerning (0 = Auto) Кернинг (0 = авто) + Keyboard Increment Приращение с клавиатуры + Keyboard Shortcut Conflicts Конфликты сочетаний клавиш + Keyboard Shortcut Set Набор сочетаний клавиш + Keyboard Shortcuts… Сочетания клавиш… + Keywords: Ключевые слова: + Knife Нож + Knockout Group Группа с вырезанием + Lab Lab + Lab Spot Colors: Плашечные цвета Lab: + Lab Values Значения Lab + Landscape Альбомная + Interface Language Язык интерфейса + Language Язык + Language Options Параметры языка + Language: Язык: + Large Большой + Large List View Большой список + Large Tabs Большие вкладки + Large Thumbnail View Крупные миниатюры + Large Thumbnails Крупные миниатюры + Lasso Tool Инструмент «Лассо» + Last Effect: {name} Последний эффект: {name} + Last Effect: {} Последний эффект: {} + Last Effect… Последний эффект… + Last Page Последняя страница + Latin Латиница + Latin Extended Расширенная латиница + Latin-1 Supplement Дополнение Latin-1 + Layer Слой + Layer Names Имена слоёв + Layer Options Параметры слоя + Layer Options… Параметры слоя… + Layers Слои + Layers Panel Options Параметры панели «Слои» + Layout: Макет: + Leader: Заполнитель: + Leading Интерлиньяж + Left Слева + Left Grid: Левая сетка: + Left in Front Левый спереди + Left Indent Отступ слева + Left Plane Левая плоскость + Left-Justified Tab Табуляция с выравниванием по левому краю + Left-to-Right Paragraph Direction Направление абзаца слева направо + Left: Слева: + Legacy Macintosh RGB (Gamma 1.8) Устаревший Macintosh RGB (гамма 1.8) + Legacy vector clipboard Устаревший векторный буфер обмена + Legacy vector clipboard (no transparency) Устаревший векторный буфер обмена (без прозрачности) + Less Меньше + Letter Letter + Levels… Уровни… + Libraries Библиотеки + Ligatures are off while the text is tracked. Лигатуры отключены, пока для текста задан трекинг. + Light Светлый + Light Blue Голубой + Lighten Замена светлым + Lightness Светлота + Limit Color Guide to Library Ограничить цветовой гид библиотекой + Limit colors to swatch library Ограничить цвета библиотекой образцов + Limit colors to swatch library: {name} Ограничить цвета библиотекой образцов: {name} + Limit to Library: Ограничить библиотекой: + Limit: Предел: + Line Линия + Line Art and Text Resolution: Разрешение штрихового рисунка и текста: + Line Breaks Разрывы строк + Line Color Цвет линии + Line Cut Разрезание линий + Line Cut Tool Инструмент «Разрезание линий» + Line Endings: Окончания линий: + Line Graph Tool Инструмент «Линейчатая диаграмма» + Line Segment Отрезок + Line Segment Tool Инструмент «Отрезок» + Line Segment Tool Options Параметры инструмента «Отрезок» + Line-end Punctuation Half Width Пунктуация в конце строки половинной ширины + Linear Gradient Линейный градиент + Lines Линии + Lines: clicks add points joined by a line Линии: каждый щелчок добавляет точки, соединённые линией + Link Связать + Link harmony colors Связать цвета гармонии + Link Info Сведения о связи + Link Opacity Mask Связать непрозрачную маску + Link start and end arrowhead scales Связать масштабы начальной и конечной стрелок + Link the mask Связать маску + Linked Связано + Linked File Связанный файл + Linked File Not Found Связанный файл не найден + Links Связи + Liquify Пластика + List Graphic Styles Перечислить графические стили + List Keyboard Shortcuts Перечислить сочетания клавиш + List Patterns Перечислить узоры + List Plug-ins Перечислить модули + List Preferences Перечислить настройки + List Slices Перечислить фрагменты + List View Список + List Workspaces Перечислить рабочие среды + Live Paint Быстрая заливка + Live Paint Bucket Быстрая заливка (ведро) + Live Paint Bucket (Stroke) Быстрая заливка (обводка) + Live Paint Info Сведения о быстрой заливке + Live Paint Selection Tool Инструмент «Выделение быстрой заливки» + Live Shape Properties Свойства динамической фигуры + Load Color Profile… Загрузить профиль цвета… + Load Image into Clipboard Загрузить изображение в буфер обмена + Load Metafile into Clipboard Загрузить метафайл в буфер обмена + Load PDF into Clipboard Загрузить PDF в буфер обмена + Load Place Cursor Загрузить курсор размещения + Load Profile… Загрузить профиль… + Load SVG into Clipboard Загрузить SVG в буфер обмена + Load Text into Clipboard Загрузить текст в буфер обмена + Local Libraries Локальные библиотеки + Locate Object Найти объект + Location Расположение + Location: Расположение: + Lock Заблокировать + Lock (⌘2) Заблокировать (⌘2) + Lock Grid Заблокировать сетку + Lock Guides Заблокировать направляющие + Lock Others Заблокировать остальные + Lock Slices Заблокировать фрагменты + Lock Station Point Заблокировать точку стояния + Lossless (lossy WebP isn't available yet) Без потерь (WebP с потерями пока недоступен) + Lossy: С потерями: + Low Низкая + lowercase строчные + Luminosity Яркость + LZW Compression Сжатие LZW + Mac Mac + Magenta Пурпурный + Magic Wand Волшебная палочка + Magic Wand Options Параметры волшебной палочки + Magic Wand Settings Настройки волшебной палочки + Magic Wand Tool Инструмент «Волшебная палочка» + Maintain Envelope Shape Сохранять форму оболочки + Make Создать + Make a theme on Create and save it. Создайте тему в разделе «Создать» и сохраните её. + Make all bleed settings the same Сделать все параметры вылета одинаковыми + Make and Expand Создать и разобрать + Make Clipping Mask Создать маску обрезки + Make Compound Shape Создать составную фигуру + Make Envelope Создать оболочку + Make Guides Создать направляющие + Make Mask Создать маску + Make Opacity Mask Создать непрозрачную маску + Make Pixel Perfect Сделать пиксельно точным + Make the bleed values differ Сделать значения вылета разными + Make with Mesh… Создать с сеткой… + Make with Top Object Создать с верхним объектом + Make with Warp… Создать с деформацией… + Make/Release Clipping Mask Создать/освободить маску обрезки + Manage Workspaces Управление рабочими средами + Manage Workspaces… Управление рабочими средами… + Manually Вручную + Mark as trapped Пометить как треппинг + Mark Older Files as [Converted] When Opened Помечать старые файлы как [Преобразованные] при открытии + Marker… Маркер… + Marks Метки + Marks and Bleed Метки и вылет + Marks and Bleeds Метки и вылеты + Match User Interface Brightness Согласовать с яркостью интерфейса + Materials… Материалы… + Matte Мат + Matte: Мат: + Maximize Развернуть + Maximum Максимум + Maximum Editability Максимальная редактируемость + Measure Tool Инструмент «Измерение» + Measurement Labels Метки измерений + Media Носитель + Medium Средняя + Medium (150 ppi) Средняя (150 ppi) + Medium Dark Тёмно-средний + Medium Light Светло-средний + Medium Thumbnail View Средние миниатюры + Medium Thumbnails Средние миниатюры + Menu Dialog Диалоговое окно меню + Merge Объединить + Merge colors into one row Объединить цвета в одну строку + Merge Graphic Styles Объединить графические стили + Merge Graphic Styles… Объединить графические стили… + Merge Layers Объединить слои + Merge Selected Объединить выбранные + Merge Swatches Объединить образцы + Mesh Tool Инструмент «Сетка» + Message: Сообщение: + Metadata: Метаданные: + Metallic Gradients Металлические градиенты + Meters Метры + Method: Метод: + Millimeters Миллиметры + Minify Минифицировать + Minimal Минимальный + Minimize Свернуть + Minus Back Минус нижний + Minus Front Минус верхний + Mirror Зеркало + Mirror & Cut Зеркало и разрез + Mirror & Cut Tool Инструмент «Зеркало и разрез» + Missing Отсутствует + Missing: {path} Отсутствует: {path} + Miter Join Соединение «митра» + Miter Limit Предел митры + Miter Limit: Предел митры: + Mixed Appearances Смешанное оформление + Mixed Objects Смешанные объекты + Mode: Режим: + Model Модель + Modified Изменено + Modified since it was read: {path} Изменено с момента чтения: {path} + Modified: Изменено: + Modify Изменить + Mojikumi Mojikumi + Mojikumi Set Набор mojikumi + Monitor RGB RGB монитора + Mono Моно + Monochrome images Монохромные изображения + Monospaced Моноширинный + More Больше + More Pathfinder options Дополнительные параметры обработки контуров + More Settings Дополнительные настройки + Move Переместить + Move All Objects Переместить все объекты + Move Anchors Переместить опорные точки + Move Appearance Переместить оформление + Move Artboard Переместить монтажную область + Move Artboard Up/Down Переместить монтажную область вверх/вниз + Move Direction Handle Переместить маркер направления + Move Down Переместить вниз + Move Effect Переместить эффект + Move Envelope Mesh Point Переместить точку сетки оболочки + Move Graphic Style Переместить графический стиль + Move Guide Переместить направляющую + Move in Perspective Переместить в перспективе + Move Locked and Hidden Artwork with Artboard Перемещать заблокированный и скрытый рисунок вместе с монтажной областью + Move Mesh Point Переместить точку сетки + Move Plane Переместить плоскость + Move Plane to Match Object Переместить плоскость по объекту + Move Rows Переместить строки + Move Slices Переместить фрагменты + Move Spine Point Переместить точку остова + Move Swatch Переместить образец + Move Up Переместить вверх + Move… Переместить… + Multiply Умножение + My Themes Мои темы + N pt per N пт на + N slices …, evenly spaced N фрагментов …, равномерно + Name Имя + Name 40% Имя 40 % + Name* @ 66.67 % (RGB/Preview) Имя* @ 66.67 % (RGB/Просмотр) + Name* @ 66.67% (RGB/Preview) Имя* @ 66.67% (RGB/Просмотр) + Name: Имя: + Navigate Навигация + Navigator Навигатор + Needs the desktop app Требуется настольное приложение + Neutral Gradients Нейтральные градиенты + New Создать + New Art Appearance Оформление нового рисунка + New Art Brush from Selection Новая художественная кисть из выделенного + New Art Has Basic Appearance Новый рисунок имеет основное оформление + New Artboard Новая монтажная область + New Bristle Brush Новая щетинная кисть + New Brush… Новая кисть… + New Calligraphic Brush Новая каллиграфическая кисть + New Character Style Новый стиль символа + New Color Group Новая цветовая группа + New Color Group… Новая цветовая группа… + New Document Presets Наборы новых документов + New file Новый файл + New from Template Создать из шаблона + New from Template… Создать из шаблона… + New Graphic Style Новый графический стиль + New Graphic Style (Alt-click to name it) Новый графический стиль (Alt+щелчок, чтобы задать имя) + New Graphic Style… Новый графический стиль… + New Layer Options… Параметры нового слоя… + New Layer… Новый слой… + New Opacity Masks Are Clipping Новые непрозрачные маски обрезают + New Opacity Masks Are Inverted Новые непрозрачные маски инвертируются + New Paragraph Style Новый стиль абзаца + New Pattern Brush from Selection Новая узорная кисть из выделенного + New PDF Preset Новый набор PDF + New Print Preset Новый набор печати + New Scatter Brush from Selection Новая кисть-распыление из выделенного + New Sublayer… Новый подcлой… + New Swatch Новый образец + New Swatch… Новый образец… + New Symbol Новый символ + New Symbol… Новый символ… + New theme from the current color Новая тема из текущего цвета + New View Новый вид + New View… Новый вид… + New Window Новое окно + New Workspace Новая рабочая среда + New Workspace… Новая рабочая среда… + New {kind} Style… Новый стиль {kind}… + New… Создать… + Next Далее + Next Library Следующая библиотека + Next Object Above Следующий объект выше + Next Object Below Следующий объект ниже + Next Page Следующая страница + Next page Следующая страница + No actions. Press ● to record. Нет действий. Нажмите ● для записи. + No assets Нет ресурсов + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Ресурсов пока нет. Соберите рисунок командой «Объект › Собрать для экспорта» или перетащите его на панель «Экспорт ресурсов». + No Break Без разрыва + No brushes Нет кистей + No colors to recolor (preserved colors are left out). Нет цветов для перекрашивания (сохранённые цвета исключены). + No CSS Нет CSS + No document Нет документа + No Document Нет документа + No document open Документ не открыт + No document open. Документ не открыт. + No filter plug-ins installed Фильтрующие модули не установлены + No fonts are available. Шрифты недоступны. + No gradient swatches Нет градиентных образцов + No graphic styles Нет графических стилей + No images Нет изображений + No matches. Совпадений нет. + No matching fonts Нет подходящих шрифтов + No options Нет параметров + No printer settings to open here Здесь нечего открывать для настроек принтера + No saved themes Нет сохранённых тем + No saved views (View → New View…) Нет сохранённых видов (Просмотр → Новый вид…) + No Selection Нет выделения + No selection Нет выделения + No symbols in this document В этом документе нет символов + No text selected Текст не выделен + No Thumbnails Без миниатюр + Noise Шум + Nonbreaking Hyphen Неразрывный дефис + Nonbreaking Space Неразрывный пробел + None Нет + None (/) Нет (/) + None (keep transparency) Нет (сохранить прозрачность) + None. Нет. + Normal Обычный + Normal Screen Mode Обычный режим экрана + Note: Примечание: + Nothing matches the Show filter. Ничего не соответствует фильтру «Показать». + Nothing to print Нечего печатать + Nudge Сдвинуть + Nudge in Perspective Сдвинуть в перспективе + Number of Artboards: Число монтажных областей: + Number of Colors: Число цветов: + Number of Recent Files to Display Число отображаемых последних файлов + Number of Recent Fonts Число последних шрифтов + Numbers Without Units Are Points Числа без единиц — это пункты + Object Объект + Object Highlighting Подсветка объектов + Object IDs: Идентификаторы объектов: + Object Name Имя объекта + Object Properties Свойства объекта + Object Selection by Path Only Выделение объектов только по контуру + Objects Объекты + Objects using them keep their look but are no longer linked. Использующие их объекты сохраняют вид, но больше не связаны. + of characters used использованных символов + of {count} из {count} + Off Выкл. + Offset Path Смещение контура + Offset Path… Смещение контура… + Offset X Смещение X + Offset: Смещение: + OK ОК + On Вкл. + On Copy При копировании + On Paste При вставке + On Screen На экране + One file per artboard: -.{ext} Один файл на монтажную область: -.{ext} + One page per artboard. Одна страница на монтажную область. + One Point Одна точка + One Point Perspective Одноточечная перспектива + One Point Perspective Preset 1 Набор одноточечной перспективы 1 + One Point Perspective Preset 2 Набор одноточечной перспективы 2 + One Point Perspective Preset 3 Набор одноточечной перспективы 3 + One Point Perspective Preset 4 Набор одноточечной перспективы 4 + One Point Perspective Preset 5 Набор одноточечной перспективы 5 + Only Web Colors Только web-цвета + Opacity Непрозрачность + Opacity & Mask Define Knockout Shape Непрозрачность и маска определяют форму вырезания + Opacity Mask Непрозрачная маска + Opacity Mask Info Сведения о непрозрачной маске + Opacity Mask Options Параметры непрозрачной маски + Opacity: Непрозрачность: + Opacity: 50% Multiply Непрозрачность: 50 % Умножение + Opacity: Default Непрозрачность: по умолчанию + Opacity: {value} Непрозрачность: {value} + Opaque Непрозрачный + Open Открыть + Open a document to edit its patterns. Откройте документ, чтобы редактировать его узоры. + Open a document to navigate it. Откройте документ для навигации по нему. + Open a document to preview its flattening. Откройте документ для предварительного просмотра объединения. + Open a document to preview its pages Откройте документ для предварительного просмотра его страниц + Open a document to see its artboards. Откройте документ, чтобы увидеть его монтажные области. + Open a document to see its graphic styles. Откройте документ, чтобы увидеть его графические стили. + Open a document to see its history. Откройте документ, чтобы увидеть его историю. + Open a document to see its swatches. Откройте документ, чтобы увидеть его образцы. + Open Document Открыть документ + Open Documents As Tabs Открывать документы как вкладки + Open Graphic Style Library Открыть библиотеку графических стилей + Open Location after Export Открывать расположение после экспорта + Open password: Пароль для открытия: + Open Paths Открытые контуры + Open Recent File 1 Открыть последний файл 1 + Open Recent File 10 Открыть последний файл 10 + Open Recent File 11 Открыть последний файл 11 + Open Recent File 12 Открыть последний файл 12 + Open Recent File 13 Открыть последний файл 13 + Open Recent File 14 Открыть последний файл 14 + Open Recent File 15 Открыть последний файл 15 + Open Recent File 16 Открыть последний файл 16 + Open Recent File 17 Открыть последний файл 17 + Open Recent File 18 Открыть последний файл 18 + Open Recent File 19 Открыть последний файл 19 + Open Recent File 2 Открыть последний файл 2 + Open Recent File 20 Открыть последний файл 20 + Open Recent File 21 Открыть последний файл 21 + Open Recent File 22 Открыть последний файл 22 + Open Recent File 23 Открыть последний файл 23 + Open Recent File 24 Открыть последний файл 24 + Open Recent File 25 Открыть последний файл 25 + Open Recent File 26 Открыть последний файл 26 + Open Recent File 27 Открыть последний файл 27 + Open Recent File 28 Открыть последний файл 28 + Open Recent File 29 Открыть последний файл 29 + Open Recent File 3 Открыть последний файл 3 + Open Recent File 30 Открыть последний файл 30 + Open Recent File 4 Открыть последний файл 4 + Open Recent File 5 Открыть последний файл 5 + Open Recent File 6 Открыть последний файл 6 + Open Recent File 7 Открыть последний файл 7 + Open Recent File 8 Открыть последний файл 8 + Open Recent File 9 Открыть последний файл 9 + Open Recent Files Открыть последние файлы + Open Swatch Library Открыть библиотеку образцов + Open the URL in the web browser Открыть URL в веб-браузере + Opened with {} note(s): {} Открыто с примечаниями ({}) : {} + Open… Открыть… + Optical Margin Alignment Оптическое выравнивание полей + Optimize for fast web view Оптимизировать для быстрого просмотра в web + Optimized Оптимизировано + Options Параметры + Options for Selection… Параметры выделения… + Options: Параметры: + Options… Параметры… + or more spaces with a tab или больше пробелов на табуляцию + Order: Порядок: + Ordinals Порядковые числительные + Orientation Ориентация + Orientation: Ориентация: + Original Оригинал + Original: click to restore Оригинал: щёлкните для восстановления + Other Другой + Other Layers Другие слои + Other Libraries Другие библиотеки + Other Library… Другая библиотека… + Other Script… Другая письменность… + Out of Gamut Warning Предупреждение о выходе из цветового охвата + Out of Web Color Warning Предупреждение о цвете, не поддерживаемом web + Out Tangent Handles Маркеры исходящей касательной + Outer Glow… Внешнее свечение… + Outline Контур + Outline Others Обвести остальные + Outline Stroke Обвести обводку + Outline Text Обвести текст + Outlines and Rules Контуры и линейки + Output Вывод + Output All Blacks Accurately Выводить все чёрные точно + Output All Blacks as Rich Black Выводить все чёрные как составной чёрный + Output Black Выходной чёрный + Output condition: Условие вывода: + Output Intent Назначение вывода + Output intent profile: Профиль назначения вывода: + Output White Выходной белый + Output › Mode Вывод › Режим + Output: Вывод: + Overlap: Перекрытие: + Overlapping Перекрывающийся + Overlay Перекрытие + Overprint Наложение + Overprint and Transparency Flattener Наложение и объединение прозрачностей + Overprint and Transparency Flattener Options Параметры наложения и объединения прозрачностей + Overprint Black… Наложение чёрного… + Overprint Fill Наложение заливки + Overprint Preview Просмотр наложения + Overprint Preview (separations) Просмотр наложения (цветоделения) + Overprint Stroke Наложение обводки + Overprint: Наложение: + Overprints: Наложения: + Override Character Color Переопределить цвет символов + Package Упаковать + Package collects a saved document with its linked files and fonts. Упаковка собирает сохранённый документ с его связанными файлами и шрифтами. + Package created Пакет создан + Packaged {n} file(s) in {folder}{note} Упаковано файлов: {n} в {folder}{note} + Packaged {n} file(s){note} Упаковано файлов: {n}{note} + Package… Упаковать… + Page Страница + Page information Сведения о странице + Page Isolated Blending Изолированное наложение страницы + Page Knockout Group Группа вырезания страницы + Pages Страницы + Paintbrush Кисть + Paintbrush Tool Инструмент «Кисть» + Panel menu Меню панели + Panel Options… Параметры панели… + Panels Панели + Paragraph Абзац + Paragraph Style Стиль абзаца + Paragraph Style Options Параметры стиля абзаца + Paragraph Styles Стили абзацев + Paragraph Symbol Символ абзаца + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. Часть ArtCraft. MIT OR Apache-2.0. Шрифты: Source Sans 3, Inter, JetBrains Mono (OFL). Значки: Lucide (ISC) + VectorCraft. + Password Пароль + Paste Вставить + Paste in Back Вставить позади + Paste in Front Вставить спереди + Paste in Place Вставить на место + Paste Offset Смещение при вставке + Paste on All Artboards Вставить на все монтажные области + Paste Remembers Layers Вставка с сохранением слоёв + Paste without Formatting Вставить без форматирования + Pasted Вставлено + Pastels Пастельные + Path Контур + Path Eraser Ластик для контуров + Path Eraser Tool Инструмент «Ластик для контуров» + Pathfinder Обработка контуров + Pathfinder Options… Параметры обработки контуров… + Pathfinders: Обработка контуров: + Paths Контуры + Paths: {paths} Anchors: {anchors} Colors: {colors} Контуры: {paths} Опорные точки: {anchors} Цвета: {colors} + Pattern Узор + Pattern Editing Редактирование узора + 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 Info Сведения о PDF + PDF Preset Набор PDF + PDF Preset: Набор PDF: + PDF Presets Наборы PDF + PDF Presets… Наборы PDF… + PDF Settings Параметры PDF + PDF/A files carry their own output intent. Файлы PDF/A содержат собственное назначение вывода. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. Файлы PDF/X-1a используют CMYK: каждый цвет преобразуется в целевой (пусто: профиль CMYK назначения вывода) без пометки. + Pen Tool Инструмент «Перо» + Pencil Карандаш + Pencil Tool Инструмент «Карандаш» + per slice на фрагмент + Percent: Проценты: + Perceptual Scales Перцепционные шкалы + Performance Производительность + Performance & Storage Производительность и хранение + Permissions Разрешения + Permissions password: Пароль разрешений: + Perspective Перспектива + Perspective Grid Сетка перспективы + Perspective Grid Options Параметры сетки перспективы + Perspective Grid Preset Набор сетки перспективы + Perspective Grid Presets Наборы сетки перспективы + Perspective grid presets Наборы сетки перспективы + Perspective Grid Presets… Наборы сетки перспективы… + Perspective Grid Settings Параметры сетки перспективы + Perspective Grid Tool Инструмент «Сетка перспективы» + Perspective Plane Options… Параметры плоскости перспективы… + Perspective Selection Tool Инструмент «Выделение в перспективе» + Phone 390×844 Телефон 390×844 + Picas Пики + Pie Graph Tool Инструмент «Круговая диаграмма» + Pixel Preview Просмотр пикселей + Pixels Пиксели + Place Поместить + Place DXF Поместить DXF + Place onto a new locked, dimmed template layer below the current layer Поместить на новый заблокированный, приглушённый слой шаблона под текущим слоем + Place PDF Поместить PDF + Place Symbol Instance Поместить экземпляр символа + Place the pages with the placement again Снова разместить страницы с помощью размещения + Placed File Info Сведения о размещённом файле + Placement Options Параметры размещения + Placement Options… Параметры размещения… + Placement: Размещение: + Place… Поместить… + Plates Пластины + Platform: Платформа: + Play Current Selection Воспроизвести текущее выделение + Plug-in Info Сведения о модуле + Plug-ins Модули + Plug-in… Модуль… + Point Sample Точечный образец + Point Text Objects Точечные текстовые объекты + Points Точки + Points: clicks add free points Точки: каждый щелчок добавляет свободные точки + Polar Grid Полярная сетка + Polar Grid Tool Инструмент «Полярная сетка» + Polygon Многоугольник + Polygon Properties: Свойства многоугольника: + Polygon Tool Инструмент «Многоугольник» + Portrait Книжная + Power Saving (integrated) Энергосбережение (встроенная) + Portuguese Португальский + Position Положение + Postcard Открытка + PostScript: PostScript: + PPI PPI + Preferences Настройки + Preferences… Настройки… + Prefix: Префикс: + Presentation Attributes Атрибуты представления + Presentation Mode Режим презентации + Preserve Alpha Transparency Сохранять альфа-прозрачность + Preserve Appearance and Overprints Сохранять оформление и наложения + Preserve CMYK Numbers Сохранять значения CMYK + Preserve Editing Capabilities Сохранять возможности редактирования + Preserve editing capabilities Сохранять возможности редактирования + Preserve Lightness Сохранять светлоту + Preserve Overprints and Spot Colors Сохранять наложения и плашечные цвета + Preserve Paths Сохранять контуры + Preserve Shape Using: Сохранять форму с помощью: + Preserve Spot Colors Сохранять плашечные цвета + Preserve Text Appearance Сохранять оформление текста + Preserve Text Editability Сохранять редактируемость текста + Preserve: Сохранять: + PRESET DETAILS СВЕДЕНИЯ О НАБОРЕ + Preset Name Имя набора + Preset name: Имя набора: + Preset {} Набор {} + Preset: Набор: + Presets you save with the Save Preset button appear here. Наборы, сохранённые кнопкой «Сохранить набор», появятся здесь. + Presets: Наборы: + Press Нажмите + Preview Просмотр + Preview All Layers Просмотреть все слои + Preview in Browser Просмотреть в браузере + Preview in Browser needs the desktop app (the web downloads files instead) Просмотр в браузере требует настольного приложения (web-версия скачивает файлы) + Preview Mode Режим просмотра + Preview the document as it is now Просмотреть документ в текущем виде + Previous Назад + Previous Library Предыдущая библиотека + Previous Page Предыдущая страница + Previous page Предыдущая страница + Primary Основной + Print Печать + Print as Bitmap Печатать как растровое изображение + Print Layers: Печатать слои: + Print Preset Набор печати + Print Preset: Набор печати: + Print Presets Наборы печати + Print presets Наборы печати + Print Presets… Наборы печати… + Print Preview Просмотр печати + Print Setup Настройка печати + Print Tiling Разбиение для печати + Print Tiling Pages Страницы разбиения для печати + Print Tiling Tool Инструмент «Разбиение для печати» + Printer Принтер + Printer mark type: Тип типографских меток: + Printer profile: Профиль принтера: + Printer Setup… Настройка принтера… + Printer: Принтер: + Printers Принтеры + Printing / Exporting Печать / Экспорт + Printing allowed: Печать разрешена: + Print… Печать… + Process Color Составной цвет + Profile inclusion: Включение профиля: + Profile: Профиль: + Progressive Прогрессивный + Projecting Cap Выступающее окончание + Proof Colors Цветопроба + Proof Setup Настройка цветопробы + Properties Свойства + Proportional Oldstyle Пропорциональные старого стиля + Pucker & Bloat… Сморщивание и раздувание… + Pucker Tool Инструмент «Сморщивание» + Punctuation Пунктуация + Punctuation Space Пунктуационная шпация + Puppet Warp Марионеточная деформация + Puppet Warp Pins Булавки марионеточной деформации + Puppet Warp Tool Инструмент «Марионеточная деформация» + Quality {quality} Качество {quality} + Quality: Качество: + Quarter Space Четвертная шпация + Quick Actions Быстрые действия + Quickly start a new file Быстро создать новый файл + Quit VectorCraft Выйти из VectorCraft + Radar Graph Tool Инструмент «Лепестковая диаграмма» + Radial Радиальный + Radial Glows Радиальные свечения + Radial Gradient Радиальный градиент + Radius 1 Радиус 1 + Radius 2 Радиус 2 + Radius1 Радиус1 + Radius2 Радиус2 + Rainbow Радуга + Random Случайно + Randomize Colors Случайные цвета + Randomly change color order Случайно менять порядок цветов + Randomly change saturation and brightness Случайно менять насыщенность и яркость + Range: Диапазон: + Raster Effects Растровые эффекты + Raster File Format: Формат растрового файла: + Raster Sample Size: Размер растрового образца: + Raster/Vector Balance: Баланс растровых/векторных: + Rasterize Растеризовать + Rasterize Unsupported Art Растеризовать неподдерживаемый рисунок + Rasterize… Растеризовать… + Rasters Растровые изображения + Rating: Рейтинг: + Real World: Реальный мир: + Real-time Drawing and Editing Рисование и редактирование в реальном времени + Rearrange All Artboards (on the roadmap) Переставить все монтажные области (в плане развития) + Rearrange All Artboards… Переставить все монтажные области… + Recall Selection Восстановить выделение + RECENT ПОСЛЕДНИЕ + Recent Последние + Recent Colors Последние цвета + Recent File Последний файл + Recent Files Последние файлы + Recent Font Последний шрифт + Recent Font 1 Последний шрифт 1 + Recent Font 10 Последний шрифт 10 + Recent Font 11 Последний шрифт 11 + Recent Font 12 Последний шрифт 12 + Recent Font 13 Последний шрифт 13 + Recent Font 14 Последний шрифт 14 + Recent Font 15 Последний шрифт 15 + Recent Font 2 Последний шрифт 2 + Recent Font 3 Последний шрифт 3 + Recent Font 4 Последний шрифт 4 + Recent Font 5 Последний шрифт 5 + Recent Font 6 Последний шрифт 6 + Recent Font 7 Последний шрифт 7 + Recent Font 8 Последний шрифт 8 + Recent Font 9 Последний шрифт 9 + Recent Fonts Последние шрифты + Recent URLs Последние URL + Recolor Перекрасить + Recolor Artwork Перекрасить рисунок + Recolor Artwork… Перекрасить рисунок… + Recolor this row Перекрасить эту строку + Recolor with Preset Перекрасить с помощью набора + Recover Documents Восстановить документы + Recovery Data Данные восстановления + Rectangle Прямоугольник + Rectangle Cut Прямоугольный разрез + Rectangle Cut Tool Инструмент «Прямоугольный разрез» + Rectangle Properties: Свойства прямоугольника: + Rectangle Tool Инструмент «Прямоугольник» + Rectangle: Sunshine Прямоугольник: Солнце + Rectangle… Прямоугольник… + Rectangular Grid Прямоугольная сетка + Rectangular Grid Tool Инструмент «Прямоугольная сетка» + Red Красный + Redefine Character Style Переопределить стиль символа + Redefine Graphic Style Переопределить графический стиль + Redefine Graphic Style “{name}” Переопределить графический стиль «{name}» + Redefine Paragraph Style Переопределить стиль абзаца + Redefine Symbol Переопределить символ + Redefine {kind} Style Переопределить стиль {kind} + Redo Повторить + Redo {name} Повторить {name} + Redo {} Повторить {} + Reduce Colors Сократить число цветов + Reduce to Basic Appearance Сократить до основного оформления + Reduction: Сокращение: + Reference Point Точка отсчёта + Reflect Отразить + Reflect Horizontal Отразить по горизонтали + Reflect Tool Инструмент «Отражение» + Reflect X Отразить по X + Reflect Y Отразить по Y + Reflect… Отразить… + Refresh Обновить + Refresh Font List Обновить список шрифтов + Registered Trademark Symbol Символ зарегистрированного товарного знака + Registration marks Метки совмещения + Registry name: Имя в реестре: + Regular Обычный + Release Освободить + Release Compound Shape Освободить составную фигуру + Release Guides Освободить направляющие + Release Opacity Mask Освободить непрозрачную маску + Release Selection Освободить выделение + Release to Layers (Build) Распределить по слоям (накопление) + Release to Layers (Sequence) Распределить по слоям (последовательность) + Release with Perspective Освободить с перспективой + Relink Восстановить связь + Relink Linked Files to the Document Восстановить связи связанных файлов с документом + Relink to Folder… Восстановить связь с папкой… + Relinked {n} image(s) Восстановлена связь с изображениями: {n} + Relinked {n} image(s); not in that folder: {} Восстановлена связь с изображениями: {n}; нет в этой папке: {} + Relinked {} image(s) Восстановлена связь с изображениями: {} + Relink… Восстановить связь… + Reload Plug-ins Перезагрузить модули + Remove Убрать + Remove Anchor Points Убрать опорные точки + Remove Asset Убрать ресурс + Remove blank lines between paragraphs Убрать пустые строки между абзацами + Remove Brush Stroke Убрать мазок кисти + Remove Effect Убрать эффект + Remove Guide Убрать направляющую + Remove Item Убрать элемент + Remove Plug-in Убрать модуль + Remove Selected Assets Убрать выбранные ресурсы + Remove Threading Убрать связывание + Remove “{keyword}” Убрать «{keyword}» + Rename Переименовать + Rename Asset Переименовать ресурс + Rename Character Style Переименовать стиль символа + Rename Graphic Style Переименовать графический стиль + Rename Paragraph Style Переименовать стиль абзаца + Rename Workspace Переименовать рабочую среду + Render Threads (-1 = Automatic) Потоки рендеринга (-1 = автоматически) + Rendering intent: Метод рендеринга: + Reorder Изменить порядок + Reorder Appearance Item Изменить порядок элемента оформления + Repeat Повторить + Repeat Options Параметры повтора + Repeat Pathfinder Повторить обработку контуров + Repeat Tab Повторить табуляцию + Repeat {name} Повторить {name} + Replace Заменить + Replace Font Заменить шрифт + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. + Replace Spine Заменить остов + Replace Symbol Заменить символ + Replace With Font Заменить шрифтом + Replaces the saved preset of that name. Заменяет сохранённый набор с таким именем. + Replace… Заменить… + Require a password to open the document Требовать пароль для открытия документа + Reselect Выделить снова + Reset Сбросить + Reset Bounding Box Сбросить ограничительную рамку + Reset Envelope Mesh Сбросить сетку оболочки + Reset Envelope Shape Сбросить форму оболочки + Reset Essentials Сбросить «Основные» + Reset Keyboard Shortcuts Сбросить сочетания клавиш + Reset OpenType Features Сбросить функции OpenType + Reset Panel Сбросить панель + Reset Preferences Сбросить настройки + Reset Profiles Сбросить профили + Reset Rotate View Сбросить поворот вида + Reset the rows to the artwork's colors Сбросить строки к цветам рисунка + Reset to Default Сбросить к значениям по умолчанию + Reset to White, Black Сбросить к белому, чёрному + Reset with Mesh Сбросить с сеткой + Reset with Mesh… Сбросить с сеткой… + Reset with Warp Сбросить с деформацией + Reset with Warp… Сбросить с деформацией… + Reset {name} Сбросить {name} + Reset {} Сбросить {} + Reshape Изменить форму + Reshape Segment Изменить форму сегмента + Reshape Tool Инструмент «Изменение формы» + Reshape Type Area Изменить форму текстовой области + Resolution: Разрешение: + Responsive Адаптивный + Restore Восстановить + Restore every preference to its default (applied on OK) Восстановить все настройки по умолчанию (применяется при нажатии ОК) + Restore Recovered Documents Восстановить восстановленные документы + Restored documents open unsaved: save them to keep them. Восстановленные документы открываются несохранёнными: сохраните их, чтобы оставить. + Restored {} recovered document(s): save them to keep them Восстановлено документов: {}: сохраните их, чтобы оставить + Restrict printing, editing and other tasks Ограничить печать, редактирование и другие задачи + Reverse Front to Back Обратный порядок спереди назад + Reverse Gradient Обратить градиент + Reverse Order Обратный порядок + Reverse Path Direction Обратить направление контура + Reverse Path Direction Off Обратить направление контура выключено + Reverse Path Direction On Обратить направление контура включено + Reverse Spine Обратить остов + Revert Вернуть + Revert to the saved version of “{name}”? Вернуться к сохранённой версии «{name}»? + Revolve… Вращение… + RGB Color Цвет RGB + Right Справа + Right Grid: Правая сетка: + Right in Front Правый спереди + Right Indent Отступ справа + Right Plane Правая плоскость + Right-Justified Tab Табуляция с выравниванием по правому краю + Right-to-Left Paragraph Direction Направление абзаца справа налево + Right: Справа: + Rise… Подъём… + Roman Baseline Римская базовая линия + Roman Hanging Punctuation Римская висячая пунктуация + Rotate Повернуть + Rotate 90° CW Повернуть на 90° по часовой стрелке + Rotate Tool Инструмент «Поворот» + Rotate View Tool Инструмент «Поворот вида» + Rotate… Повернуть… + Rotation Поворот + Roughen… Огрубление… + Round Cap Круглое окончание + Round Corners… Скруглить углы… + Round Join Круглое соединение + Rounded Скруглённый + Rounded Rectangle Скруглённый прямоугольник + Rounded Rectangle Tool Инструмент «Скруглённый прямоугольник» + Rounded Rectangle… Скруглённый прямоугольник… + Row Size: Размер строки: + Rows: Строки: + Rule: Линейка: + Rulers Линейки + Rulers & Grids Линейки и сетки + Run Plug-in Запустить модуль + Same То же + Same As Source Как в источнике + Sample Color Взять образец цвета + Sans Serif / Gothic Sans Serif / Готический + Saturate Насытить + Saturate… Насытить… + Saturation Насыщенность + Save Сохранить + Save a Copy Сохранить копию + Save a Copy… Сохранить копию… + Save As Сохранить как + Save as PDF… Сохранить как PDF… + Save as preset: Сохранить как набор: + Save as Template Сохранить как шаблон + Save as Template… Сохранить как шаблон… + Save As… Сохранить как… + Save changes to {name} Сохранить изменения в {name} + Save color group to Swatch panel Сохранить цветовую группу на панели «Образцы» + Save Colors as Swatches Сохранить цвета как образцы + Save Document Сохранить документ + Save Document Info… Сохранить сведения о документе… + Save each artboard to a separate file Сохранять каждую монтажную область в отдельный файл + Save for Office Documents Сохранить для документов Office + Save for Office Documents… Сохранить для документов Office… + Save for Web Сохранить для web + Save for Web (Legacy)… Сохранить для web (устаревший)… + Save for Web Presets Наборы «Сохранить для web» + Save for Web Preview Просмотр «Сохранить для web» + Save for Web Settings Параметры «Сохранить для web» + Save Graphic Style Library Сохранить библиотеку графических стилей + Save Graphic Style Library… Сохранить библиотеку графических стилей… + Save Grid as Preset Сохранить сетку как набор + Save Grid as Preset… Сохранить сетку как набор… + Save PDF Сохранить PDF + Save PDF Dialog Диалоговое окно «Сохранить PDF» + Save PDF Preset Сохранить набор PDF + Save Perspective Grid Preset Сохранить набор сетки перспективы + Save Preset Сохранить набор + Save Preset… Сохранить набор… + Save Print Preset Сохранить набор печати + Save Recovery Data Сохранить данные восстановления + Save Save for Web Preset Сохранить набор «Сохранить для web» + Save selected colors as swatches Сохранить выбранные цвета как образцы + Save Selection… Сохранить выделение… + Save Swatch Library Сохранить библиотеку образцов + Save Swatch Library… Сохранить библиотеку образцов… + Save the current layout under this name Сохранить текущий макет под этим именем + Save the document first? Сначала сохранить документ? + Save the selected preset to a file Сохранить выбранный набор в файл + Save the settings as a preset Сохранить параметры как набор + Save Theme Сохранить тему + Save theme to My Themes Сохранить тему в «Мои темы» + Save these settings as a preset Сохранить эти параметры как набор + Save these settings as a print preset Сохранить эти параметры как набор печати + Save to Swatches Сохранить в образцы + Save To: Сохранить в: + Save Transparency Flattener Preset Сохранить набор объединения прозрачностей + Save Transparency Flattener Preset… Сохранить набор объединения прозрачностей… + SAVED СОХРАНЁННЫЕ + Saved Сохранено + Saved recovery data for {title} Сохранены данные восстановления для {title} + Saved Selection 1 Сохранённое выделение 1 + Saved Selection 10 Сохранённое выделение 10 + Saved Selection 11 Сохранённое выделение 11 + Saved Selection 12 Сохранённое выделение 12 + Saved Selection 13 Сохранённое выделение 13 + Saved Selection 14 Сохранённое выделение 14 + Saved Selection 15 Сохранённое выделение 15 + Saved Selection 16 Сохранённое выделение 16 + Saved Selection 17 Сохранённое выделение 17 + Saved Selection 18 Сохранённое выделение 18 + Saved Selection 19 Сохранённое выделение 19 + Saved Selection 2 Сохранённое выделение 2 + Saved Selection 20 Сохранённое выделение 20 + Saved Selection 21 Сохранённое выделение 21 + Saved Selection 22 Сохранённое выделение 22 + Saved Selection 23 Сохранённое выделение 23 + Saved Selection 24 Сохранённое выделение 24 + Saved Selection 25 Сохранённое выделение 25 + Saved Selection 3 Сохранённое выделение 3 + Saved Selection 4 Сохранённое выделение 4 + Saved Selection 5 Сохранённое выделение 5 + Saved Selection 6 Сохранённое выделение 6 + Saved Selection 7 Сохранённое выделение 7 + Saved Selection 8 Сохранённое выделение 8 + Saved Selection 9 Сохранённое выделение 9 + Saved Selections Сохранённые выделения + Saved to User Defined: {} Сохранено в «Пользовательский»: {} + Saved View 1 Сохранённый вид 1 + Saved View 10 Сохранённый вид 10 + Saved View 2 Сохранённый вид 2 + Saved View 3 Сохранённый вид 3 + Saved View 4 Сохранённый вид 4 + Saved View 5 Сохранённый вид 5 + Saved View 6 Сохранённый вид 6 + Saved View 7 Сохранённый вид 7 + Saved View 8 Сохранённый вид 8 + Saved View 9 Сохранённый вид 9 + Saved Views Сохранённые виды + Saved {n} files beside {path} Сохранено файлов: {n} рядом с {path} + Saved {path} Сохранено: {path} + Saved {path} with {} note(s): {first} Сохранено: {path} с примечаниями ({}) : {first} + Saves the current bars, toolbar and panel layout. Сохраняет текущее расположение панелей, панели инструментов и панелей. + Save… Сохранить… + Saving Сохранение + Saving Poster.vectorcraft Сохранение Poster.vectorcraft + Saving recovery data for {} Сохранение данных восстановления для {} + Saving {} Сохранение {} + Scale Масштабировать + Scale Corners Масштабировать углы + Scale Cursor Proportional to UI Масштабировать курсор пропорционально интерфейсу + Scale Lineweights Масштабировать толщины линий + Scale Strokes & Effects Масштабировать обводки и эффекты + Scale to Fit Artboard Масштабировать по монтажной области + Scale Tool Инструмент «Масштаб» + Scale: Масштаб: + Scale… Масштабировать… + Scaling Масштабирование + Scaling: Масштабирование: + Scallop Tool Инструмент «Фестоны» + Scans: Сканы: + Scatter Рассеивание + Scatter Graph Tool Инструмент «Точечная диаграмма» + Scissors Tool Инструмент «Ножницы» + Scratch Disks Рабочие диски + Screen Растр + Screen (72 ppi) Экран (72 ppi) + Screen Mode Режим экрана + Screens Экраны + Scribble… Каракули… + Script / Brush Рукописный / Кисть + Scripts Сценарии + Search Поиск + Search All Искать везде + Search commands and tools Поиск команд и инструментов + Search commands and tools… Поиск команд и инструментов… + Search Commands… Поиск команд… + Secondary Вторичный + Section Symbol Символ параграфа + Security Безопасность + Select Выделение + Select a text object Выберите текстовый объект + Select a text object to edit its character attributes. Выберите текстовый объект, чтобы редактировать атрибуты символов. + Select a text object to edit its paragraph attributes. Выберите текстовый объект, чтобы редактировать атрибуты абзаца. + Select a text object to set its tab stops. Выберите текстовый объект, чтобы задать его табуляции. + Select All Выделить всё + Select All Art on Layer Выделить весь рисунок на слое + Select All Assets Выделить все ресурсы + Select All Instances Выделить все экземпляры + Select All Pins Выделить все булавки + Select All Unused Выделить всё неиспользуемое + Select an image to see its Link Info. Выберите изображение, чтобы увидеть сведения о связи. + Select an image to trace Выберите изображение для трассировки + 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 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 Same Tint % Выделить с тем же % оттенка + Select Slices Выделить фрагменты + Select styled art and click New Graphic Style. Выделите оформленный рисунок и нажмите «Новый графический стиль». + Select text Выделите текст + Select Text by Font Выделить текст по шрифту + Select text or click into text with the Type tool to insert glyphs Выделите текст или щёлкните внутри текста инструментом «Текст», чтобы вставить глифы + Select text to set its OpenType features. Выделите текст, чтобы задать его функции OpenType. + Select the art (and, on top of it, the mask object) Выделите рисунок (и поверх него — объект маски) + Select Tool Инструмент «Выделение» + Select: Выделить: + Selected Artwork Выделенный рисунок + Selected Styles Only Только выбранные стили + Selected Swatches Выбранные образцы + Selected Swatches Only Только выбранные образцы + Selection Выделение + Selection & Anchor Display Отображение выделения и опорных точек + Selection Only Только выделение + Selection Tool Инструмент «Выделение» + 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 Просмотр цветоделений + Serialize Document Сериализовать документ + Serif / Mincho Serif / Mincho + Server-side (.map) Серверная часть (.map) + Set 1 Набор 1 + Set Anchors Задать опорные точки + Set base color Задать базовый цвет + Set base color to the current color Сделать текущий цвет базовым + Set Bounds Задать границы + Set Current Layer Сделать слой текущим + Set Key Object Задать ключевой объект + Set Keyboard Shortcut Задать сочетание клавиш + Set Mesh Point Color Задать цвет точки сетки + Set Preference Задать настройку + Set Slice Rectangle Задать прямоугольник фрагмента + Set Tab Stops Задать табуляции + Set Text Задать текст + Set the selected color to the current color Назначить текущий цвет выбранному цвету + Set Zoom Задать масштаб + Settings… Параметры… + Setup… Настройка… + Shadows and Glows Тени и свечения + Shape Фигура + Shape Builder Создание фигур + Shape Builder Regions Области создания фигур + Shape Builder Tool Инструмент «Создание фигур» + Shape Effects Эффекты формы + Shape Modes: Режимы фигур: + Shape Properties: Свойства фигуры: + Shaper Tool Инструмент «Формообразование» + Shapes Фигуры + Shear Наклонить + Shear Tool Инструмент «Наклон» + Shear… Наклонить… + Shell Lower… Раковина снизу… + Shell Upper… Раковина сверху… + Shift Shift + Shift to Color… Сдвиг к цвету… + Shifts the tints of global and spot colors; other colors stay. Сдвигает оттенки глобальных и плашечных цветов; остальные цвета не меняются. + Show Показать + Show Active Plane Widget Показать виджет активной плоскости + Show All Показать всё + Show All Hidden Attributes Показать все скрытые атрибуты + Show All Layers Показать все слои + Show All Plates Показать все пластины + Show all plates Показать все пластины + Show All Swatches Показать все образцы + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Показывать и печатать по определению Lab; файлы PDF содержат его как альтернативный цвет краски. + Show and separate through the working CMYK space, as in older documents. Показывать и разделять через рабочее пространство CMYK, как в старых документах. + Show Artboards Показать монтажные области + Show Bounding Box Показать ограничительную рамку + Show Brush Size Показать размер кисти + Show Center Показать центр + Show Code Показать код + Show Color Groups Показать цветовые группы + Show Color Swatches Показать цветовые образцы + Show Corner Widget Показать угловой виджет + Show East Asian Options Показать параметры восточноазиатских языков + Show Edges Показать края + Show Embedded Показать внедрённые + Show Find Field Показать поле поиска + Show Font Names in English Показывать названия шрифтов на английском + Show Gradient Annotator Показать аннотатор градиента + Show Gradient Swatches Показать градиентные образцы + Show Grid Показать сетку + Show Grid (⌘') Показать сетку (⌘') + Show Guides Показать направляющие + Show handles when multiple anchors are selected Показывать маркеры при выделении нескольких опорных точек + Show Hidden Characters Показать скрытые символы + Show Images in Outline Mode Показывать изображения в режиме контуров + Show in Folder Показать в папке + Show Indic Options Показать параметры индийских языков + Show Layers Only Показывать только слои + Show Link Info Показать сведения о связи + Show List View Показать список + Show Mesh Показать сетку + Show Missing Показать отсутствующие + Show Modified Показать изменённые + Show Note Показать примечание + Show Options Показать параметры + Show Package Показать пакет + Show Panel Показать панель + Show Pattern Swatches Показать образцы узоров + Show Perspective Grid Показать сетку перспективы + Show Pixel Grid (Above 600% Zoom) Показывать пиксельную сетку (при масштабе выше 600 %) + Show Print Tiling Показать разбиение для печати + Show Rulers Показать линейки + Show Rulers (⌘R) Показать линейки (⌘R) + Show Slice Numbers Показать номера фрагментов + Show Slices Показать фрагменты + Show Swatch Bounds Показать границы образца + Show Swatch Kinds Показать типы образцов + Show Text Threads Показать связи текста + Show The Home Screen When No Documents Are Open Показывать начальный экран, когда документы не открыты + Show Thumbnail Показать миниатюру + Show Thumbnail View Показать миниатюры + Show Thumbnails Показать миниатюры + Show Tile Edge Показать край плитки + Show Tints/Shades Показать оттенки/тени + Show Tool Tips Показывать подсказки + Show Transparency Grid Показать сетку прозрачности + Show Video Rulers Показать видео-линейки + Show Vivid/Muted Показать яркие/приглушённые + Show Warm/Cool Показать тёплые/холодные + Show {kind} Brushes Показать кисти {kind} + Show/hide plate (Alt-click: only this plate) Показать/скрыть пластину (Alt+щелчок: только эту пластину) + Show: Показать: + Shown with the optimised preview. Показано с оптимизированным просмотром. + Side 1: Сторона 1: + Side 2: Сторона 2: + Sides: Стороны: + Simplify Упростить + Simplify: Упростить: + Simplify… Упростить… + Simulate Colored Paper Имитировать цветную бумагу + Simulate Paper Имитировать бумагу + Double Column Двойной столбец + Single Left Quotation Mark Одинарная открывающая кавычка + Single Quotes: Одинарные кавычки: + Single Right Quotation Mark Одинарная закрывающая кавычка + Single-line Composer Однострочный компоновщик + Sixth Space Шестая шпация + Size Размер + Size Tile to Art Подогнать плитку по рисунку + Size/Leading Размер/интерлиньяж + Size: Размер: + Size: {size} Tracking: {tracking} Размер: {size} Трекинг: {tracking} + Skew Скос + Skin Tone Ramps Шкалы тонов кожи + Skip Blank Artboards Пропускать пустые монтажные области + Sky Gradients Небесные градиенты + Slice Фрагмент + Slice Options Параметры фрагмента + Slice Options… Параметры фрагмента… + Slice Selection Tool Инструмент «Выделение фрагментов» + Slice Tool Инструмент «Фрагмент» + Slice Type: Тип фрагмента: + Slices Фрагменты + slices across, evenly spaced фрагментов по ширине, равномерно + slices down, evenly spaced фрагментов по высоте, равномерно + Slices: Фрагменты: + Small Малый + Small Caps Капитель + Small Caps (on the roadmap) Капитель (в плане развития) + Small Caps: Капитель: + Small List View Малый список + Small Thumbnail View Мелкие миниатюры + Small Thumbnails Мелкие миниатюры + Smaller ↔ Larger Меньше ↔ Больше + Smart Guides Умные направляющие + Smart Guides (View → Smart Guides) Умные направляющие (Просмотр → Умные направляющие) + Smart Guides (⌘U) Умные направляющие (⌘U) + Smart Punctuation… Умная пунктуация… + Smooth Сгладить + Smooth Color Сглаженный цвет + Smooth Tool Инструмент «Сглаживание» + Snap Curves To Lines Привязывать кривые к линиям + Snap Options Параметры привязки + Snap to Glyph Привязать к глифу + Snap to Grid Привязать к сетке + Snap to Pixel Привязать к пикселю + Snap to Point Привязать к точке + Snapping Tolerance Допуск привязки + Social Square Post 1080×1080 Квадратный пост для соцсетей 1080×1080 + Soft Light Мягкий свет + Solid Сплошной + Sort by Kind Сортировать по типу + Sort by Name Сортировать по имени + Sort by Status Сортировать по состоянию + Source code on GitHub Исходный код на GitHub + Source Sans 3 Source Sans 3 + Space After Paragraph Интервал после абзаца + Space Before Paragraph Интервал перед абзацем + Spaces Пробелы + Spacing Guides Направляющие интервалов + Spacing: Интервал: + Spanish Испанский + Specified Distance Заданное расстояние + Specified Steps Заданное число шагов + Specify: Задать: + Spectrum Gradients Спектральные градиенты + Spelling Орфография + Spiral Спираль + Spiral Tool Инструмент «Спираль» + Split Freeform Line Разделить произвольную линию + Split Into Grid Разделить на сетку + Split Into Grid… Разделить на сетку… + Split Path Разделить контур + spot плашечный + Spot Color Плашечный цвет + Spot Colors Плашечные цвета + Spot Colors… Плашечные цвета… + Spread: Разворот: + Square Квадрат + Squeeze… Сжатие… + Stacked Bar Graph Tool Инструмент «Гистограмма с накоплением» + Stacked Column Graph Tool Инструмент «Гистограмма с накоплением» + Stair Step Ступенчатый + Standard Ligatures Стандартные лигатуры + Standard Vertical Roman Alignment Стандартное вертикальное римское выравнивание + Standard: Стандартный: + Star Звезда + Star Tool Инструмент «Звезда» + Start arrowhead Начальная стрелка + Start Global Edit Начать глобальное редактирование + Status Состояние + Steps: Шаги: + Stop Editing Opacity Mask Прекратить редактирование непрозрачной маски + Stop editing the opacity mask Прекратить редактирование непрозрачной маски + Stop Playing/Recording Остановить воспроизведение/запись + Stray Points Отдельные точки + Strikethrough Зачёркивание + Stroke Обводка + Stroke (X), double-click for the Color Picker Обводка (X), дважды щёлкните для палитры цветов + Stroke Color Цвет обводки + Stroke Options Параметры обводки + Character: Символ: + Character options Параметры символа + Stroke options Параметры обводки + Transform options Параметры трансформирования + Stroke Weight Толщина обводки + Stroke: Обводка: + Stroke: click for swatches, Shift-click for the color mixer Обводка: щёлкните для образцов, Shift+щелчок — для смесителя цветов + Style Стиль + Style Attributes Атрибуты стиля + Style Attributes (Entity References) Атрибуты стиля (ссылки на сущности) + Style Name: Имя стиля: + Style: Стиль: + Styling: Стилизация: + Stylize Стилизация + Subdivisions Подразделения + Subscript Нижний индекс + Subscript (on the roadmap) Нижний индекс (в плане развития) + Subscript: Нижний индекс: + Subset fonts below: Создавать подмножества шрифтов ниже: + Subtract Вычесть + Suffix Суффикс + Summary Сводка + Superscript Верхний индекс + Superscript (on the roadmap) Верхний индекс (в плане развития) + Superscript: Верхний индекс: + SVG Filters Фильтры SVG + SVG Interactivity Интерактивность SVG + SVG Options Параметры SVG + SVG Profile: Профиль SVG: + Swap Fill and Stroke Поменять заливку и обводку + Swap Fill and Stroke (Shift+X) Поменять заливку и обводку (Shift+X) + Swap start and end arrowheads Поменять начальную и конечную стрелки + Swap the selected object for the file, keeping its place and transform Заменить выделенный объект файлом, сохранив его положение и преобразование + Swash Росчерк + Swatch Conflict Конфликт образцов + Swatch Conflicts Конфликты образцов + Swatch Libraries Библиотеки образцов + Swatch Libraries Menu Меню «Библиотеки образцов» + Swatch Library Библиотека образцов + Swatch Name: Имя образца: + Swatch Options Параметры образца + Swatch Options… Параметры образца… + Swatch Tiles Плитки образцов + Swatches Образцы + Switch workspace Переключить рабочую среду + Symbol Instance Экземпляр символа + Symbol Libraries Библиотеки символов + Symbol Libraries (on the roadmap) Библиотеки символов (в плане развития) + Symbol Screener Tool Инструмент «Прозрачность символов» + Symbol Scruncher Tool Инструмент «Сжатие символов» + Symbol Shifter Tool Инструмент «Сдвиг символов» + Symbol Sizer Tool Инструмент «Размер символов» + Symbol Spinner Tool Инструмент «Вращение символов» + Symbol Sprayer Распылитель символов + Symbol Sprayer Tool Инструмент «Распылитель символов» + Symbol Stainer Tool Инструмент «Тонирование символов» + Symbol Styler Tool Инструмент «Стилизация символов» + Symbolism Tool Инструмент «Символизм» + Symbols Символы + System Layout Системный макет + Tab Табуляция + Tab Stops Табуляции + Tabs Табуляции + Tabular Lining Табличные цифры + Tabular Oldstyle Табличные старого стиля + Target Объект + Target Contents Содержимое объекта + Target: Объект: + Tate-chu-yoko Tate-chu-yoko + Temperature Температура + Temperature/Tint… Температура/оттенок… + Template Шаблон + Templates Шаблоны + Templates Folder Папка шаблонов + Templates… Шаблоны… + Text Displayed in Cell: Текст, отображаемый в ячейке: + Text Export Options Параметры экспорта текста + Text Fill Color Цвет заливки текста + Text Import Options Параметры импорта текста + Text Stroke Color Цвет обводки текста + Text Wrap Обтекание текстом + Text Wrap Options Параметры обтекания текстом + Text Wrap Options… Параметры обтекания текстом… + The bounds of the visible art. Границы видимого рисунка. + The cell shows the type object's text as HTML. Ячейка показывает текст текстового объекта как HTML. + The document has no colour swatches. В документе нет цветовых образцов. + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Край бумаги, до которого не доходит принтер: рисунок остаётся внутри него, а просмотр показывает его пунктиром. + The page as it prints Страница в том виде, в каком она печатается + the preset набор + The Print Tiling tool placed the pages. Страницы размещены инструментом «Разбиение для печати». + The selected objects have no CSS of their own (see below). У выделенных объектов нет собственного CSS (см. ниже). + The system's settings of this printer Системные настройки этого принтера + Thin Space Тонкая шпация + Third Space Третья шпация + Third Vanishing Point: Третья точка схода: + This document already has a swatch named “{name}” with another color. В этом документе уже есть образец с именем «{name}» и другим цветом. + This library is empty. Эта библиотека пуста. + This panel is on the roadmap (see the parity plan). Эта панель в плане развития (см. план паритета). + Threaded Text Связанный текст + Threads Связи + Three Point Три точки + Three Point Perspective Трёхточечная перспектива + Three Point Perspective Preset 1 Набор трёхточечной перспективы 1 + Three Point Perspective Preset 2 Набор трёхточечной перспективы 2 + Three Point Perspective Preset 3 Набор трёхточечной перспективы 3 + Three Point Perspective Preset 4 Набор трёхточечной перспективы 4 + Three Point Perspective Preset 5 Набор трёхточечной перспективы 5 + Threshold Порог + Thumbnail View Миниатюры + Thumbnails: Миниатюры: + Tile Плитка + Tile Edge Color Цвет края плитки + Tile Edge Color… Цвет края плитки… + Tile Full Pages Плитка целых страниц + Tile Range: Диапазон плиток: + Tile Type: Тип плитки: + tile {number} плитка {number} + Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. + Tint Оттенок + Tip extends past the end point Кончик выходит за конечную точку + Tip on the end point Кончик на конечной точке + Title Case Как в заголовке + to в + Toggle Fill/Stroke Focus Переключить фокус заливки/обводки + Toggle Selection Переключить выделение + Toggle single/double column Переключить один/два столбца + Tolerance Допуск + Tolerance: Допуск: + Tool Option Параметр инструмента + Tool Options… Параметры инструмента… + Toolbar: Advanced / Basic Панель инструментов: расширенная / основная + Toolbar: Single/Double Column Панель инструментов: один/два столбца + Toolbars Панели инструментов + Tools Инструменты + Top Вверху + Top in Front Верхний спереди + Top: Вверху: + Total Width: Общая ширина: + Touch Type Tool Инструмент «Настройка текста» + Trace Трассировать + Trace the image with the Default preset Трассировать изображение с набором «По умолчанию» + Tracking Трекинг + Trademark Symbol Символ товарного знака + Transform Трансформировать + Transform Again Трансформировать снова + Transform Both Трансформировать оба + Transform Each Трансформировать каждый + Transform Each… Трансформировать каждый… + Transform in Perspective Трансформировать в перспективе + Transform Object Only Трансформировать только объект + Transform Panel Панель «Трансформирование» + Transform Pattern Only Трансформировать только узор + Transform Pattern Tiles Трансформировать плитки узора + Transform Patterns Трансформировать узоры + Transform Tools Инструменты трансформирования + Transforms Трансформирования + Transform… Трансформировать… + Transparency Прозрачность + 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 Options Параметры прозрачности + Transparent Прозрачный + Transparent (Default) Прозрачный (по умолчанию) + Transparent Background Прозрачный фон + Transverse (the page a quarter turn on the paper) Поперечная (страница повёрнута на четверть оборота на листе) + Trap… Треппинг… + Trim Подрезать + Trim mark weight: Толщина меток подрезки: + Trim marks Метки подрезки + Trim View Просмотр с подрезкой + True colour needs version 2004 or later: the nearest of 256 colours is written. Истинный цвет требует версии 2004 или новее: записывается ближайший из 256 цветов. + Turn off Data Recovery for complex documents Отключить восстановление данных для сложных документов + Tweak… Настройка… + Twirl Rate: Скорость закручивания: + Twirl Tool Инструмент «Закручивание» + Twist… Кручение… + Two Point Две точки + Two Point Perspective Двухточечная перспектива + Two Point Perspective Preset 1 Набор двухточечной перспективы 1 + Two Point Perspective Preset 2 Набор двухточечной перспективы 2 + Two Point Perspective Preset 3 Набор двухточечной перспективы 3 + Two Point Perspective Preset 4 Набор двухточечной перспективы 4 + Two Point Perspective Preset 5 Набор двухточечной перспективы 5 + Type Текст + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Текст преобразуется в контуры, а обводки, которые не может нарисовать линия CAD, становятся заполненными фигурами. + Type Object Selection by Path Only Выделение текстовых объектов только по контуру + Type on a Path Текст по контуру + Type on a Path Options Параметры текста по контуру + Type on a Path Options… Параметры текста по контуру… + Type on a Path Tool Инструмент «Текст по контуру» + Type Orientation Ориентация текста + Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами. + Type Tool Инструмент «Текст» + Type: Тип: + Type… Текст… + UI Brightness Яркость интерфейса + UI Scaling Масштаб интерфейса + Underline Подчёркивание + Undo Отменить + Undo {name} Отменить {name} + Undo {} Отменить {} + Unembed Извлечь + Unembedded to {} Извлечено в {} + Unembed… Извлечь… + Ungroup Разгруппировать + Ungroup Color Group Разгруппировать цветовую группу + Unicode Unicode + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Uniform Равномерный + Unique Уникальный + Unite Объединение + Unite and Simplify Объединить и упростить + Units Единицы + Units: Единицы: + Units: {units} Единицы: {units} + Unlink harmony colors Разъединить цвета гармонии + Unlink Opacity Mask Разъединить непрозрачную маску + Unlink the mask Разъединить маску + Unlock Разблокировать + Unlock All Разблокировать всё + Unlock All Layers Разблокировать все слои + Unlock Grid Разблокировать сетку + Unlock Guides Разблокировать направляющие + Unnamed Objects Объекты без имени + Unprintable margin: Непечатаемое поле: + Unshift Отменить сдвиг + Untitled Без названия + Update Link Обновить связь + Update Links Обновить связи + Update Modified Links Обновить изменённые связи + Updated {} image(s) Обновлено изображений: {} + UPPERCASE ПРОПИСНЫЕ + URL: URL: + Use Использовать + Use Artboards Использовать монтажные области + Use Black Point Compensation Использовать компенсацию чёрной точки + Use Compression Использовать сжатие + Use document bleed settings Использовать параметры вылета документа + Use Even-Odd Fill Rule Использовать правило заливки «чёт-нечет» + Use Japanese Crop Marks Использовать японские метки обрезки + Use Low Resolution Proxy for Linked EPS Использовать низкоразрешённый прокси для связанных EPS + Use Non-Zero Winding Fill Rule Использовать правило заливки «ненулевой» + Use Precise Cursors Использовать точные курсоры + Use Pressure Pen Использовать перо с нажимом + Use Preview Bounds Использовать границы просмотра + Use Registration Point for Symbol Использовать точку совмещения для символа + Use Square for Previews Использовать квадрат для просмотра + Use Text for Previews Использовать текст для просмотра + Use the document's swatch: pasted colors take its color. Использовать образец документа: вставленные цвета принимают его цвет. + Use the Selected Text's Font Использовать шрифт выделенного текста + Use Typographer's Quotes Использовать типографские кавычки + User Defined Пользовательский + User Defined Libraries Пользовательские библиотеки + User Graphic Style Library 1 Пользовательская библиотека графических стилей 1 + User Graphic Style Library 10 Пользовательская библиотека графических стилей 10 + User Graphic Style Library 2 Пользовательская библиотека графических стилей 2 + User Graphic Style Library 3 Пользовательская библиотека графических стилей 3 + User Graphic Style Library 4 Пользовательская библиотека графических стилей 4 + User Graphic Style Library 5 Пользовательская библиотека графических стилей 5 + User Graphic Style Library 6 Пользовательская библиотека графических стилей 6 + User Graphic Style Library 7 Пользовательская библиотека графических стилей 7 + User Graphic Style Library 8 Пользовательская библиотека графических стилей 8 + User Graphic Style Library 9 Пользовательская библиотека графических стилей 9 + User Interface Интерфейс пользователя + User Library Пользовательская библиотека + User Preset Пользовательский набор + User Swatch Library 1 Пользовательская библиотека образцов 1 + User Swatch Library 10 Пользовательская библиотека образцов 10 + User Swatch Library 2 Пользовательская библиотека образцов 2 + User Swatch Library 3 Пользовательская библиотека образцов 3 + User Swatch Library 4 Пользовательская библиотека образцов 4 + User Swatch Library 5 Пользовательская библиотека образцов 5 + User Swatch Library 6 Пользовательская библиотека образцов 6 + User Swatch Library 7 Пользовательская библиотека образцов 7 + User Swatch Library 8 Пользовательская библиотека образцов 8 + User Swatch Library 9 Пользовательская библиотека образцов 9 + V Spacing: Интервал по вертикали: + V: В: + Variable Width Profile Профиль переменной ширины + Variables Переменные + Variation: Вариация: + Vector Effects Векторные эффекты + Vector Halftone Векторный полутон + Vector Halftone… Векторный полутон… + Vector illustration — fast, open, scriptable. Векторная иллюстрация — быстрая, открытая, программируемая. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft не завершился нормально в прошлый раз. В этих документах были несохранённые изменения: + VectorCraft Help… Справка VectorCraft… + VectorCraft on getartcraft.com VectorCraft на getartcraft.com + VectorCraft on GitHub VectorCraft на GitHub + Vectors Векторы + Version {version} — open-source vector illustration in pure Rust. Версия {version} — векторная иллюстрация с открытым исходным кодом на чистом Rust. + Version: Версия: + Vertical Вертикально + Vertical % По вертикали, % + Vertical Align Bottom Выровнять по нижнему краю по вертикали + Vertical Align Center Выровнять по центру по вертикали + Vertical Align Top Выровнять по верхнему краю по вертикали + Vertical Area Type Tool Инструмент «Вертикальный текст в области» + Vertical Distribute Bottom Распределить по нижнему краю по вертикали + Vertical Distribute Center Распределить по центру по вертикали + Vertical Distribute Space Распределить интервалы по вертикали + Vertical Distribute Top Распределить по верхнему краю по вертикали + Vertical Scale % Масштаб по вертикали, % + Vertical Type on a Path Tool Инструмент «Вертикальный текст по контуру» + Vertical Type Tool Инструмент «Вертикальный текст» + Vertical: По вертикали: + Very High Очень высокая + View Просмотр + View Artboard Only Просмотр только монтажной области + View Opacity Mask Просмотр непрозрачной маски + View PDF after saving Просматривать PDF после сохранения + View {n} Вид {n} + Viewing Angle: Угол обзора: + Viewing Distance: Расстояние обзора: + W: Ш: + Warnings Предупреждения + Warp Деформация + Warp Options Параметры деформации + Warp Tool Инструмент «Деформация» + Wave… Волна… + Web 1920×1080 Web 1920×1080 + Web Safe 216 216 web-безопасных + Web Safe RGB Web-безопасный RGB + Web Shift Сдвиг к web + Web Snap: Привязка к web: + Weight Толщина + Weight: Толщина: + Welcome to VectorCraft Добро пожаловать в VectorCraft + When pasting text При вставке текста + White Белый + Widget Position: Положение виджета: + Width Ширина + Width and Height Ширина и высота + Width Point Точка ширины + Width Point Edit Редактирование точки ширины + Width Point Edit… Редактирование точки ширины… + Width Profile Профиль ширины + Width Profiles Профили ширины + Width Tool Инструмент «Ширина» + Width: Ширина: + Window Окно + Windows Windows + Working CMYK Рабочий CMYK + Working CMYK (don't tag) Рабочий CMYK (без пометки) + Workspace Рабочая среда + Workspace {i} Рабочая среда {i} + Wrinkle Tool Инструмент «Морщины» + Write Layers Записывать слои + XML ID XML ID + Yards Ярды + Yellow Жёлтый + Your browser downloads the files (several as one .zip). Ваш браузер скачивает файлы (несколько — одним .zip). + Your changes will be lost if you don't save them. Если не сохранить, изменения будут потеряны. + Zig Zag… Зигзаг… + Zoom In Увеличить + Zoom Out Уменьшить + Zoom to Selection Масштаб по выделению + Zoom Tool Инструмент «Масштаб» + Zoom with Mouse Wheel Масштабировать колесом мыши + 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} + {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}: назовите их или включите «Объекты без имени» в меню панели. + {effect} is already applied {effect} уже применён + {format} Options Параметры {format} + {kind} Gradient Градиент {kind} + {kind} Style Options… Параметры стиля {kind}… + {kind}, {model} {kind}, {model} + {name} (spot) {name} (плашечный) + {pages} in all, {count} per copy Всего {pages}, по {count} на копию + {page} of {count} {page} из {count} + {percent}% opacity {percent}% непрозрачности + {secs} sec {secs} с + {shortcut} with the tool sizes the brush {shortcut} с инструментом изменяет размер кисти + {title} (selection) {title} (выделение) + {tool} Options Параметры {tool} + {tool} Tool Options Параметры инструмента {tool} + {warning}: click to correct to the closest color ({hex}) {warning}: щёлкните для исправления на ближайший цвет ({hex}) + “{file}” is protected by a password. «{file}» защищён паролем. + ● Recording ● Запись +@plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов +@plural Color Table ({n} color)|Color Table ({n} colors) Цветовая таблица ({n} цвет)|Цветовая таблица ({n} цвета)|Цветовая таблица ({n} цветов) +@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Удалить этот графический стиль ({n})?|Удалить эти графические стили ({n})?|Удалить эти графические стили ({n})? +@plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв +@plural {n} Object|{n} Objects {n} объект|{n} объекта|{n} объектов +@plural {n} area highlighted|{n} areas highlighted {n} область выделена|{n} области выделено|{n} областей выделено +@plural {n} color|{n} colors {n} цвет|{n} цвета|{n} цветов +@plural {n} fill|{n} fills {n} заливка|{n} заливки|{n} заливок +@plural {n} glyph|{n} glyphs {n} глиф|{n} глифа|{n} глифов +@plural {n} object highlighted|{n} objects highlighted {n} объект выделен|{n} объекта выделено|{n} объектов выделено +@plural {n} star|{n} stars {n} звезда|{n} звезды|{n} звёзд +@plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов +@plural {n} stroke|{n} strokes {n} обводка|{n} обводки|{n} обводок + the object to select | в объект для его выделения | + to select multiple objects | для выделения нескольких объектов | + the object to duplicate объект для его дублирования + an anchor or path segment to select it | в опорную точку или сегмент контура для его выделения | + to move | для перемещения | + to add для добавления + to add a corner point | для добавления угловой точки | + to add a smooth point | для добавления сглаженной точки | + to close | для замыкания | + to finish для завершения + to add smooth points | для добавления сглаженных точек | + to toggle corner | для переключения угла | + to create point type | для создания точечного текста | + to create area type | для создания текста в области | + to exit editing для выхода из редактирования + to draw | для рисования | + to constrain proportions | для сохранения пропорций | + from center | от центра | + for exact size для точного размера + to set the reference point | для задания точки отсчёта | + to transform | для трансформирования | + for exact values для точных значений + to pan the view для панорамирования вида + to zoom in | для увеличения | + to zoom out | для уменьшения | + to zoom into an area для увеличения области + an object to copy its attributes | в объект для копирования его атрибутов | + to apply the selection's to it | для применения атрибутов выделения к нему | + to sample a color для взятия образца цвета + across a selected object to set the gradient direction по выделенному объекту для задания направления градиента + to select an artboard | для выбора монтажной области | + on the canvas to create one на холсте для создания одной + a stroke to add a width point | обводку для добавления точки ширины | + to change one side | для изменения одной стороны | + to select more points | для выделения большего числа точек | + a point to edit it | в точку для её редактирования | + to remove points для удаления точек + the art to add a pin | в рисунок для добавления булавки | + to select more pins | для выделения большего числа булавок | + a pin to warp | булавку для деформации | + near a selected pin to rotate | рядом с выбранной булавкой для поворота | + to remove pins для удаления булавок + across paths to push them along | по контурам для их сдвига | + to size the brush ( для изменения размера кисти ( + keeps its proportions) сохраняет свои пропорции) + over paths, and hold still to keep going | по контурам; задержите, чтобы продолжить | + over paths to roughen their outlines | по контурам для огрубления их очертаний | + to move in perspective | для перемещения в перспективе | + to copy | для копирования | + while dragging to move perpendicular to the plane | при перетаскивании для перемещения перпендикулярно плоскости | + to scale in perspective для масштабирования в перспективе + an object, then another to blend them | в объект, затем в другой для их перехода | + to blend from it | для перехода от него | + to set spacing and orientation для задания интервала и ориентации + : : + Isolate Selected Group Изолировать выбранную группу + Release Clipping Mask Освободить маску обрезки + Make Compound Path Создать составной контур + Release Compound Path Освободить составной контур + — substituted by {style} — заменён на {style} + — {n} characters from another font — {n} символов из другого шрифта + [Normal Character Style] [Обычный стиль символа] + [Normal Paragraph Style] [Обычный стиль абзаца] + +# Leading model (Paragraph panel menu, East Asian options). + Top-to-Top Leading Интерлиньяж от верха до верха + Bottom-to-Bottom Leading Интерлиньяж от низа до низа + Burasagari Burasagari + Force Принудительно + +# About › Contributors / Models (credits.rs) + About О программе + Contributors Участники + Models Модели + Username Имя пользователя + Display name Отображаемое имя + Real name Настоящее имя + Sort Сортировать + Reverse the order Обратный порядок + Grab bag Произвольный набор + Table Таблица + Name (A–Z) Имя (А–Я) + Merged PRs Объединённые PR + Commits Коммиты + Lines added Добавленные строки + Lines deleted Удалённые строки + Line delta Разница строк + Binary assets added Добавленные бинарные ресурсы + Binary assets removed Удалённые бинарные ресурсы + First commit Первый коммит + Last commit Последний коммит + First Первый + Last Последний + {people} contributors · {commits} commits {people} участников · {commits} коммитов + No contributor data was built into this copy. В эту копию не включены данные об участниках. + No model credits were built into this copy. В эту копию не включены сведения о моделях. + Company Компания + Version Версия + % of all commits % от всех коммитов + Lines +/− Строки +/− + Joins Соединения + Miter Митра + Bevel Скос + Offset Смещение + Axis Ось +axis Horizontal Горизонтально +axis Vertical Вертикально +axis Both Оба + Drag to move Перетащите для перемещения + Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов + Put back in the toolbar Вернуть на панель инструментов + Float Panel Сделать панель плавающей + Dock Panel Прикрепить панель + Drag to float the panel group Перетащите, чтобы сделать группу панелей плавающей + Put back in the dock Вернуть в док + Drag to float the toolbar Перетащите, чтобы сделать панель инструментов плавающей + Drag to the window's left edge to dock Перетащите к левому краю окна для прикрепления + Float Tool Group Сделать группу инструментов плавающей + +# 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. +@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 +@msg Blend colors: select at least three objects with filled colours Смешивание цветов: выберите не менее трёх объектов с цветными заливками +@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 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} +@msg Couldn't place {name}: the document it was dropped on was closed Не удалось поместить {name}: документ, в который его перетащили, был закрыт +@msg Couldn't read {name}: {e} Не удалось прочитать {name}: {e} +@msg Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e} +@msg Create Outlines: select text objects Создание контуров: выделите текстовые объекты +@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 Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Восстановлению данных негде хранить копии: задайте Настройки → Работа с файлами → Восстановление данных → Папка (recoveryFolder) +@msg direct-select anchor points to cut at (not the end points of open paths) выделите прямым выделением опорные точки для разреза (не конечные точки открытых контуров) +@msg Divide Objects Below: select a path Разделение объектов ниже: выделите контур +@msg Divide Objects Below: select exactly one path Разделение объектов ниже: выделите ровно один контур +@msg Downloaded {_1} file(s) Скачано файлов: {_1} +@msg Downloaded {what} Скачано: {what} +@msg Expand Appearance: the selection has a basic appearance Разбор оформления: у выделения основное оформление +@msg Expand: no brush strokes selected Разбор: не выделены мазки кисти +@msg Expand: no gradient fills Разбор: нет градиентных заливок +@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 Image Trace: the traced image is missing Трассировка изображения: трассируемое изображение отсутствует +@msg Imported {what}: {_1} Импортировано: {what}: {_1} +@msg Installed plug-in {_1} Установлен модуль {_1} +@msg Internal error: {msg} (please report this bug) Внутренняя ошибка: {msg} (пожалуйста, сообщите об этой ошибке) +@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 Make Guides: select paths Создание направляющих: выделите контуры +@msg No saved selections (Select → Save Selection…) Нет сохранённых выделений (Выделение → Сохранить выделение…) +@msg No saved views (View → New View…) Нет сохранённых видов (Просмотр → Новый вид…) +@msg Offset Path: select paths Смещение контура: выделите контуры +@msg Opened with {_1} note(s): {_2} Открыто с примечаниями ({_1}) : {_2} +@msg Opened {url} Открыто {url} +@msg Outline Stroke: no stroked paths selected Обводка контура: не выделены контуры с обводкой +@msg PDF settings are not an object параметры PDF не являются объектом +@msg PDF writer error: {0} ошибка записи PDF: {0} +@msg Packaged {n} file(s) in {folder}{note} Упаковано файлов: {n} в {folder}{note} +@msg Packaged {n} file(s){note} Упаковано файлов: {n}{note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Просмотр в браузере требует настольного приложения (web-версия скачивает файлы) +@msg Release Guides: no guides Освобождение направляющих: нет направляющих +@msg Release: no layer to put the rectangle in Освобождение: нет слоя для помещения прямоугольника +@msg Replace Spine: select a blend and a path Замена остова: выделите переход и контур +@msg select two or more objects to distribute выделите два или более объектов для распределения +@msg select paths or anchor points выделите контуры или опорные точки +@msg SVG parse error: {0} ошибка разбора SVG: {0} +@msg Save for Web: the image is empty Сохранить для web: изображение пусто +@msg Save writes {_1}, not {_2}: use document.export for other formats Сохранение записывает {_1}, а не {_2}: используйте document.export для других форматов +@msg Saved recovery data for {title} Сохранены данные восстановления для {title} +@msg Saved to User Defined: {_1} Сохранено в «Пользовательский»: {_1} +@msg Saved {path} Сохранено: {path} +@msg Select text or click into text with the Type tool to insert glyphs Выделите текст или щёлкните внутри текста инструментом «Текст», чтобы вставить глифы +@msg Shape Builder: no region under the pointer Создание фигур: под указателем нет области +@msg Shape Builder: select paths or compound paths Создание фигур: выделите контуры или составные контуры +@msg Show All Hidden Attributes: nothing is hidden Показать все скрытые атрибуты: ничего не скрыто +@msg Split Into Grid: select paths large enough for the grid Разделение на сетку: выделите контуры, достаточно большие для сетки +@msg Targa files are RGB: colorModel rgb файлы Targa являются RGB: colorModel rgb +@msg Text Wrap: select a wrap object Обтекание текстом: выделите объект обтекания +@msg `aspect` must be a number (%) `aspect` должно быть числом (%) +@msg `at` must be [x, y] `at` должно быть [x, y] +@msg `color` must be a colour `color` должно быть цветом +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` должно быть командой, пункт меню которой открывает диалоговое окно (см. ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` должно быть массивом +@msg `gradient` must be an object `gradient` должно быть объектом +@msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list}) +@msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 +@msg `stops` must be an array `stops` должно быть массивом +@msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) +@msg `{_1}` is no save command `{_1}` не является командой сохранения +@msg `{area}` is no recovery area name `{area}` не является именем области восстановления +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` не является цветом (#rrggbb или именем набора) +@msg `{id}` has no envelope dialog `{id}` не имеет диалогового окна оболочки +@msg `{keep}` is not a side of this axis `{keep}` не является стороной этой оси +@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 number `{key}` должно быть числом +@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 `{key}` must be true or false `{key}` должно быть true или false +@msg `{k}` must be [x, y] `{k}` должно быть [x, y] +@msg `{name}` has no options `{name}` не имеет параметров +@msg `{name}` is a built-in workspace; choose another name `{name}` — встроенная рабочая среда; выберите другое имя +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` — составной цвет: оттенки применяются к глобальным и плашечным цветам +@msg `{name}` is no recovery entry name `{name}` не является именем записи восстановления +@msg `{name}` isn't a colour swatch `{name}` не является цветовым образцом +@msg `{path}` must be a bool or an object of bools `{path}` должно быть логическим значением или объектом логических значений +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` не является цветом (#rrggbb или именем набора) +@msg `{tool}` isn't a Liquify tool `{tool}` не является инструментом пластики +@msg `{tool}` isn't a freehand tool `{tool}` не является инструментом рисования от руки +@msg `{to}` is not an available name `{to}` не является доступным именем +@msg a PostScript error ({name} in `{op}`) ошибка PostScript ({name} в `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} ошибка PostScript ({name} в `{op}`), в {procs} +@msg a PostScript error ({name} in `{op}`: {at}) ошибка PostScript ({name} в `{op}`: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} ошибка PostScript ({name} в `{op}`: {at}), в {procs} +@msg a PostScript error ({name}) ошибка PostScript ({name}) +@msg a bitmap is empty растровое изображение пусто +@msg a bitmap is too large to read растровое изображение слишком велико для чтения +@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 artboard документу нужна как минимум одна монтажная область +@msg a document needs at least one layer документу нужен как минимум один слой +@msg a freeform gradient keeps at least one point произвольный градиент сохраняет как минимум одну точку +@msg a gradient needs at least two stops градиенту нужны как минимум две точки перехода +@msg a graphic style named `{to}` already exists графический стиль с именем `{to}` уже существует +@msg a page is missing страница отсутствует +@msg a page out of range страница вне диапазона +@msg a page tree kid isn't a reference потомок дерева страниц не является ссылкой +@msg a page tree node has no kids узел дерева страниц не имеет потомков +@msg a page's placement is not finite размещение страницы не конечно +@msg a preset named `{_1}` exists набор с именем `{_1}` уже существует +@msg a slice size must be a positive number of points размер фрагмента должен быть положительным числом пунктов +@msg a stream runs past the end поток выходит за конец +@msg a stream's length is wrong длина потока неверна +@msg a stream's length isn't a number длина потока не является числом +@msg add a format to export добавьте формат для экспорта +@msg already editing a pattern уже редактируется узор +@msg an object can't be read объект не может быть прочитан +@msg an object in perspective would leave the edited grid объект в перспективе вышел бы за пределы редактируемой сетки +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art или type +@msg artboard {0} does not exist монтажная область {0} не существует +@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} пт, что больше холста: выберите меньший масштаб или впишите его в монтажную область +@msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800" +@msg bad color {v} недопустимый цвет {v} +@msg bad colour `{hex}` (#rrggbb) недопустимый цвет `{hex}` (#rrggbb) +@msg bad page tree недопустимое дерево страниц +@msg bad shortcut недопустимое сочетание клавиш +@msg bad stream length недопустимая длина потока +@msg bad trailer недопустимый трейлер +@msg balance must be between 0 and 100 баланс должен быть от 0 до 100 +@msg batch step {_1} (`{id}`) failed: {e} шаг пакета {_1} (`{id}`) не выполнен: {e} +@msg brightness must be dark|mediumDark|mediumLight|light яркость должна быть dark|mediumDark|mediumLight|light +@msg can't encode the document: {0} не удалось закодировать документ: {0} +@msg can't parse shortcut `{c}` не удалось разобрать сочетание `{c}` +@msg can't parse shortcut `{s}` for `{k}` не удалось разобрать сочетание `{s}` для `{k}` +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} не удалось сохранить версию {0}: VectorCraft записывает версии от {MIN_VERSION} до {VERSION} +@msg can't write .{ext} images не удалось записать изображения .{ext} +@msg cannot distort a zero-size bounding box нельзя исказить ограничительную рамку нулевого размера +@msg cannot read PDF: {0} не удалось прочитать PDF: {0} +@msg cannot split an open path at its end point нельзя разделить открытый контур в его конечной точке +@msg check an asset to export отметьте ресурс для экспорта +@msg choice must be "merge" or "add", not `{choice}` choice должно быть "merge" или "add", а не `{choice}` +@msg choose a direction to divide in выберите направление для разделения +@msg choose a folder to export to выберите папку для экспорта +@msg choose a location for the package выберите расположение для пакета +@msg clipboard is empty буфер обмена пуст +@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 command `{0}` is not available right now: {1} команда `{0}` сейчас недоступна: {1} +@msg compound paths can only contain paths составные контуры могут содержать только контуры +@msg compressed (RLE) bitmaps can't be read yet сжатые (RLE) растровые изображения пока не читаются +@msg damaged повреждено +@msg decimals must be {_1}–{_2} (got {_3}) число десятичных знаков должно быть {_1}–{_2} (получено {_3}) +@msg divide into 1 to {MAX_PIECES} slices разделите на 1–{MAX_PIECES} фрагментов +@msg effect {k} is `{_1}`, not `{id}` эффект {k} — это `{_1}`, а не `{id}` +@msg empty brush stroke пустой мазок кисти +@msg empty cutter пустой резак +@msg empty path пустой контур +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) версия файла {0} новее, чем поддерживает этот VectorCraft ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color` +@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного) +@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами +@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины +@msg guides are locked направляющие заблокированы +@msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено +@msg image data `{_1}` is missing данные изображения `{_1}` отсутствуют +@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` установка из пути недоступна в web; передайте модуль как `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} +@msg invalid image data for `{0}` недопустимые данные изображения для `{0}` +@msg invalid parameters for `{cmd}`: {msg} недопустимые параметры для `{cmd}`: {msg} +@msg is not an image VectorCraft reads не является изображением, которое читает VectorCraft +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know использует `{at}`, которое не знает читатель PostScript в VectorCraft +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} использует `{at}`, которое не знает читатель PostScript в VectorCraft, в {procs} +@msg its Windows metafile preview was opened instead, which may simplify the art вместо этого было открыто его предварительное представление метафайла Windows, что может упростить рисунок +@msg its preview was placed instead, at screen resolution вместо этого было помещено его предварительное представление в разрешении экрана +@msg join needs open paths соединение требует открытых контуров +@msg lang must be one of {_1} lang должен быть одним из: {_1} +@msg layers can't be wrap objects слои не могут быть объектами обтекания +@msg missing `folder` отсутствует `folder` +@msg missing `id` (see plugin.list) отсутствует `id` (см. plugin.list) +@msg missing `name` отсутствует `name` +@msg missing `tool` отсутствует `tool` +@msg missing index отсутствует index +@msg multiple windows land with M11.5 несколько окон появятся в M11.5 +@msg name the file дайте файлу имя +@msg no CSS to copy: select objects with CSS of their own нет CSS для копирования: выделите объекты с собственным CSS +@msg no Live Paint edge near that point рядом с этой точкой нет границы быстрой заливки +@msg no Live Paint face at that point в этой точке нет грани быстрой заливки +@msg no PNG encoder нет кодера PNG +@msg no active document нет активного документа +@msg no active perspective plane нет активной плоскости перспективы +@msg no anchor points selected опорные точки не выделены +@msg no app to open files here здесь нет приложения для открытия файлов +@msg no artboard нет монтажной области +@msg no artboard {i} (0-based; the document has {_1}) нет монтажной области {i} (счёт с 0; в документе {_1}) +@msg no artboard {i} (0-based; the document has {count}) нет монтажной области {i} (счёт с 0; в документе {count}) +@msg no artboards named монтажные области не указаны +@msg no artwork нет рисунка +@msg no blend options нет параметров перехода +@msg no bounds нет границ +@msg no brush named `{name}` нет кисти с именем `{name}` +@msg no catalog нет каталога +@msg no changes since the last save нет изменений с момента последнего сохранения +@msg no clock to keep a heartbeat by нет часов для поддержания сигнала +@msg no colour group `{g}` нет цветовой группы `{g}` +@msg no cross-reference table нет таблицы перекрёстных ссылок +@msg no current layer нет текущего слоя +@msg no data нет данных +@msg no dialog open нет открытого диалогового окна +@msg no document нет документа +@msg no document open нет открытого документа +@msg no effect applied yet эффект ещё не применён +@msg no effect at index {k} нет эффекта с индексом {k} +@msg no end record нет конечной записи +@msg no file manager here здесь нет файлового менеджера +@msg no file reader нет средства чтения файлов +@msg no file system here здесь нет файловой системы +@msg no file writer нет средства записи файлов +@msg no files to place нет файлов для размещения +@msg no format to export to нет формата для экспорта +@msg no free recovery area нет свободной области восстановления +@msg no gradient stop or freeform point is selected не выделена ни точка перехода градиента, ни произвольная точка +@msg no graphic style `{name}` нет графического стиля `{name}` +@msg no graphic style `{n}` нет графического стиля `{n}` +@msg no interaction in progress нет взаимодействия в процессе +@msg no key object нет ключевого объекта +@msg no library `{key}` (see {list}) нет библиотеки `{key}` (см. {list}) +@msg no native format нет собственного формата +@msg no pages нет страниц +@msg no pattern `{name}` нет узора `{name}` +@msg no perspective grid preset named `{name}` нет набора сетки перспективы с именем `{name}` +@msg no plug-in {0} is installed модуль {0} не установлен +@msg no plug-in {id} is installed модуль {id} не установлен +@msg no point {i} (the gradient has {_1}) нет точки {i} (в градиенте {_1}) +@msg no preset editor нет редактора наборов +@msg no previous transform нет предыдущего преобразования +@msg no print preset named `{base}` нет набора печати с именем `{base}` +@msg no print preset named `{name}` нет набора печати с именем `{name}` +@msg no print service нет службы печати +@msg no revert command нет команды возврата +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) нет сохранённого набора PDF с именем `{name}` (встроенные наборы доступны только для чтения: создайте новый на их основе) +@msg no saved presets нет сохранённых наборов +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) нет сохранённого набора печати с именем `{name}` ({DEFAULT_PRESET} защищён: создайте новый на его основе) +@msg no saved profiles нет сохранённых профилей +@msg no saved selections нет сохранённых выделений +@msg no saved themes нет сохранённых тем +@msg no segment {segment} on line {line} нет сегмента {segment} на строке {line} +@msg no selected slice in the image: select slices, or save all of them в изображении не выделен фрагмент: выделите фрагменты или сохраните все +@msg no selection bounds у выделения нет границ +@msg no slice selected фрагмент не выделен +@msg no such action такого действия нет +@msg no such anchor такой опорной точки нет +@msg no such artboard такой монтажной области нет +@msg no such document такого документа нет +@msg no such layer or object такого слоя или объекта нет +@msg no such object {0} объект {0} не существует +@msg no such perspective grid preset такого набора сетки перспективы нет +@msg no such recent file такого последнего файла нет +@msg no such recent font такого последнего шрифта нет +@msg no such saved selection такого сохранённого выделения нет +@msg no such segment такого сегмента нет +@msg no such subpath такого вложенного контура нет +@msg no such user library такой пользовательской библиотеки нет +@msg no such view такого вида нет +@msg no such width point такой точки ширины нет +@msg no swatch `{name}` нет образца `{name}` +@msg no swatch library `{l}` (see swatch.library.list) нет библиотеки образцов `{l}` (см. swatch.library.list) +@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 workspace `{from}` нет пользовательской рабочей среды `{from}` +@msg node {_1} is not text узел {_1} не является текстом +@msg not a DXF drawing не является чертежом DXF +@msg not a VectorCraft file: {0} не является файлом VectorCraft: {0} +@msg not a VectorCraft shortcut set (missing `overrides`) не является набором сочетаний клавиш VectorCraft (отсутствует `overrides`) +@msg not a graphic style library (format `{_1}`) не является библиотекой графических стилей (формат `{_1}`) +@msg not a stored central record не является сохранённой центральной записью +@msg not a valid plug-in module: {0} не является допустимым модулем: {0} +@msg not an EMF or WMF file не является файлом EMF или WMF +@msg not found не найдено +@msg not in pattern editing mode не в режиме редактирования узора +@msg nothing encoded ничего не закодировано +@msg nothing selected ничего не выделено +@msg nothing to align нечего выравнивать +@msg nothing to distort нечего искажать +@msg nothing to export: the document has no visible art (or turn on Use Artboards) нечего экспортировать: в документе нет видимого рисунка (или включите «Использовать монтажные области») +@msg nothing to preview нечего просматривать +@msg nothing to print нечего печатать +@msg nothing to redo нечего повторять +@msg nothing to save: the document has no visible art нечего сохранять: в документе нет видимого рисунка +@msg nothing to undo нечего отменять +@msg nothing to warp нечего деформировать +@msg nothing to wrap нечего обтекать +@msg object {0} cannot have children объект {0} не может иметь потомков +@msg object {id} is not a path объект {id} не является контуром +@msg page size {w} × {h} размер страницы {w} × {h} +@msg page {0} does not exist (the PDF has {1}) страница {0} не существует (в PDF {1}) +@msg paste unavailable вставка недоступна +@msg path is already closed контур уже замкнут +@msg paths contain invalid coordinates контуры содержат недопустимые координаты +@msg paths: give at least one file paths: укажите как минимум один файл +@msg pattern missing узор отсутствует +@msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым +@msg percent must be above 0 and at most 6400, not {p} процент должен быть больше 0 и не более 6400, а не {p} +@msg pick a colour, gradient or pattern to save as a swatch выберите цвет, градиент или узор для сохранения в качестве образца +@msg plane must be left, right or ground плоскость должна быть left, right или ground +@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 print settings are not an object параметры печати не являются объектом +@msg printing isn't available here печать здесь недоступна +@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 recovery copy `{file}`: {e} копия восстановления `{file}`: {e} +@msg resolutions must be between 1 and 2400 ppi разрешения должны быть от 1 до 2400 ppi +@msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона) +@msg scale must be a number from {_1} to {_2}, not {_3} масштаб должен быть числом от {_1} до {_2}, а не {_3} +@msg scale must be a number from {_1} to {_2}, not {s} масштаб должен быть числом от {_1} до {_2}, а не {s} +@msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным +@msg select a Live Paint group выделите группу быстрой заливки +@msg select a blend выделите переход +@msg select a clip group выделите группу обрезки +@msg select a gradient mesh выделите градиентную сетку +@msg select a linked image: an embedded one has no file выделите связанное изображение: у внедрённого нет файла +@msg select a repeat выделите повтор +@msg select a stroke with a variable width выделите обводку с переменной шириной +@msg select a symbol instance выделите экземпляр символа +@msg select an Image Trace object выделите объект трассировки изображения +@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 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 выделите объект в перспективе +@msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания +@msg select at least two objects выделите как минимум два объекта +@msg select objects, not layers выделите объекты, а не слои +@msg select one path, or give its `id` выделите один контур или укажите его `id` +@msg select paths to join выделите контуры для соединения +@msg select the art to repeat выделите рисунок для повтора +@msg select the artwork to warp выделите рисунок для деформации +@msg select the objects to transform in perspective выделите объекты для трансформирования в перспективе +@msg select two or more graphic styles to merge выделите два или более графических стилей для объединения +@msg select type in perspective выделите текст в перспективе +@msg selection has no bounds у выделения нет границ +@msg shadings of an unknown type were left out заливки неизвестного типа были пропущены +@msg shadings of more than 4096 patches or triangles were left out заливки с более чем 4096 фрагментов или треугольников были пропущены +@msg shadings whose functions are of an unknown type are filled with their middle colour заливки, функции которых имеют неизвестный тип, заполняются своим средним цветом +@msg shortcut for `{k}` must be a string сочетание для `{k}` должно быть строкой +@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 the EMF header is damaged заголовок EMF повреждён +@msg the PDF has no pages в PDF нет страниц +@msg the PDF is no longer loaded PDF больше не загружен +@msg the PDF is password-protected: give its password PDF защищён паролем: укажите его пароль +@msg the PDF password is wrong неверный пароль PDF +@msg the Save PDF dialog didn't open диалоговое окно «Сохранить PDF» не открылось +@msg the WMF header is damaged заголовок WMF повреждён +@msg the art could not be sampled не удалось взять образец рисунка +@msg the art has no bounds у рисунка нет границ +@msg the art is too light for any dots рисунок слишком светлый для каких-либо точек +@msg the artboard to fit must be a positive size up to {_1} pt монтажная область для вписывания должна иметь положительный размер до {_1} пт +@msg the background worker stopped фоновый процесс остановлен +@msg the compressed file unpacks to more than {_1} MB сжатый файл распаковывается более чем в {_1} МБ +@msg the document has never been saved документ никогда не сохранялся +@msg the document has no artboard в документе нет монтажной области +@msg the document has no artboards в документе нет монтажных областей +@msg the document has no patterns в документе нет узоров +@msg the document has no slices в документе нет фрагментов +@msg the document has no symbols в документе нет символов +@msg the drawing has no layout `{_1}` (it has {_2}) в чертеже нет компоновки `{_1}` (есть {_2}) +@msg the drawing is no longer loaded чертёж больше не загружен +@msg the drawing is too large to open чертёж слишком велик для открытия +@msg the export region is empty область экспорта пуста +@msg the export region is not finite область экспорта не конечна +@msg the export returned unreadable data экспорт вернул нечитаемые данные +@msg the export wrote no file экспорт не записал ни одного файла +@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 the file was read up to an error ({why}): what follows it was left out файл был прочитан до ошибки ({why}): то, что следует за ней, было пропущено +@msg the file's preview header points outside the file заголовок предварительного просмотра файла указывает за пределы файла +@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру +@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 the object already has an opacity mask у объекта уже есть непрозрачная маска +@msg the object has no geometry у объекта нет геометрии +@msg the object has no opacity mask у объекта нет непрозрачной маски +@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 package came back damaged пакет вернулся повреждённым +@msg the pointer is beyond the plane's horizon указатель находится за горизонтом плоскости +@msg the preview image is empty изображение предварительного просмотра пусто +@msg the print job has no data задание печати не содержит данных +@msg the scale is not a usable number масштаб не является пригодным числом +@msg the selected stroke's profile is already listed as {_1} профиль выбранной обводки уже указан как {_1} +@msg the selection has no URL (or they differ) у выделения нет URL (или они различаются) +@msg the selection isn't in perspective выделение не в перспективе +@msg the stroke has no weight у обводки нет толщины +@msg the thumbnail image is malformed изображение миниатюры повреждено +@msg the top object must be a path, compound path or text object to use as a clipping mask верхний объект должен быть контуром, составным контуром или текстовым объектом, чтобы использовать его как маску обрезки +@msg the {tool} tool has no options инструмент {tool} не имеет параметров +@msg there are no objects to release нет объектов для освобождения +@msg there are no printer settings to open here здесь нет настроек принтера для открытия +@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 file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью document.exportForScreens +@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 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 (.ai, сохранённый в старом формате или без совместимости с PDF, либо файл EPS), который пока невозможно открыть: сохраните его как PDF, .ai с совместимостью с PDF или SVG +@msg this is not a PostScript file это не файл PostScript +@msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift) +@msg truncated усечено +@msg unknown command `{0}` неизвестная команда `{0}` +@msg unknown effect `{id}` неизвестный эффект `{id}` +@msg unknown format неизвестный формат +@msg unknown format `{f}` (Save writes {_1}) неизвестный формат `{f}` (сохранение записывает {_1}) +@msg unknown freeform mode `{m}` (points, lines) неизвестный режим произвольной формы `{m}` (points, lines) +@msg unknown gradient kind `{k}` (linear, radial, freeform) неизвестный тип градиента `{k}` (linear, radial, freeform) +@msg unknown highlight `{h}` неизвестное выделение `{h}` +@msg unknown option `{path}.{k}` неизвестный параметр `{path}.{k}` +@msg unknown overprints `{o}` (preserve, simulate or discard) неизвестные наложения `{o}` (preserve, simulate или discard) +@msg unknown panel `{raw}` неизвестная панель `{raw}` +@msg unknown preference `{key}` неизвестная настройка `{key}` +@msg unknown preset (one of {_1}) неизвестный набор (один из: {_1}) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) неизвестный набор `{name}` (high, medium, low или сохранённый: см. flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) неизвестный набор `{name}` (см. flattener.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) неизвестный набор `{name}` (см. webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) неизвестная сторона `{other}` (left|right|top|bottom) +@msg unknown tool `{tool}` неизвестный инструмент `{tool}` +@msg unknown tool `{t}` неизвестный инструмент `{t}` +@msg unknown tool or command `{key}` неизвестный инструмент или команда `{key}` +@msg unknown workspace (one of {_1}) неизвестная рабочая среда (одна из: {_1}) +@msg unsupported: {0} не поддерживается: {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файлы версии {0} не могут быть сжаты: только версия {COMPRESSED_SINCE} и новее открывают сжатые файлы +@msg width and height are at least 1 pixel ширина и высота должны быть не менее 1 пикселя +@msg workspace name can't be empty имя рабочей среды не может быть пустым +@msg {C}: no effect at index {from} {C}: нет эффекта с индексом {from} +@msg {_1} failed: {e} {_1} завершился с ошибкой: {e} +@msg {_1} files can be opened but not written (see document.formats) файлы {_1} можно открыть, но не записать (см. document.formats) +@msg {_1} font families available доступно семейств шрифтов: {_1} +@msg {_1} is a live effect (Effect › Plug-ins) {_1} — динамический эффект (Эффект › Модули) +@msg {_1} is already used by {_2} {_1} уже используется {_2} +@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 {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт +@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются +@msg {command} didn't create an object {command} не создал объект +@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 {label}… {label}… +@msg {name}: CRC mismatch {name}: несовпадение CRC +@msg {name}: no local header {name}: нет локального заголовка +@msg {name}: no such recovery entry {name}: такой записи восстановления нет +@msg {path} can't be found: relink it first {path} не найден: сначала восстановите связь +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: здесь нет файловой системы (передайте dataBase64 для открытия; опустите путь, чтобы получить dataBase64) +@msg {path}: {e} {path}: {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} Не удалось поместить {name}: {e} +@msg Couldn't paste from the clipboard: {e} Не удалось вставить из буфера обмена: {e} +@msg Saved {path} with {_1} note(s): {first} Сохранено: {path} с примечаниями ({_1}) : {first} +@msg Exported {what} with {_1} note(s): {first} Экспортировано: {what} с примечаниями ({_1}) : {first} +@msg Exported {what} Экспортировано: {what} +@msg Saved {n} files beside {path} Сохранено файлов: {n} рядом с {path} +@msg Saving Сохранение +@msg Saving {_1} Сохранение {_1} +@msg Exporting {_1} Экспорт {_1} +@msg Saving recovery data for {_1} Сохранение данных восстановления для {_1} +@msg Discarded {_1} recovered document(s) Отменено восстановленных документов: {_1} +@msg Restored {_1} recovered document(s): save them to keep them Восстановлено документов: {_1}: сохраните их, чтобы оставить +@msg Embedded {n} image(s); {missing} missing: relink them first Внедрено изображений: {n}; отсутствует {missing}: сначала восстановите связи +@msg Embedded {n} image(s) Внедрено изображений: {n} +@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 Updated {_1} image(s) Обновлено изображений: {_1} +@msg Unembedded to {_1} Извлечено в {_1} +@msg Reset {_1} Сброшено: {_1} +@msg Sent to {_1} Отправлено в {_1} +@msg {_1} files {_1} файлов +@msg The graphics were lost and have been restarted Графика была потеряна и перезапущена +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} Графический процессор, выбранный первым, не смог показать окно, поэтому VectorCraft запустился снова на {summary} +@msg Loading VectorCraft… Загрузка VectorCraft… +@msg VectorCraft failed to start: {e} VectorCraft не удалось запустить: {e} +@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 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 должен называть панель, плавающую в другой группе +@msg x and y must both be numbers x и y должны быть числами + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Службы + Hide VectorCraft Скрыть VectorCraft + Zoom Масштаб + Bring All to Front Все окна на передний план + 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. Выкл.: редактируемая копия его рисунка, внедрённая в этот документ. + Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения. + VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 5253a0ea..972990dd 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -418,7 +418,7 @@ fn cs() -> Lang { } /// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English. -const KEEPS_MENU_NAMES: [&str; 5] = ["cs", "es", "it", "ja", "pt-br"]; +const KEEPS_MENU_NAMES: [&str; 6] = ["cs", "es", "it", "ja", "pt-br", "ru"]; /// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace /// names and the perspective grid presets (names, shown untranslated wherever else they appear). @@ -555,6 +555,10 @@ fn it() -> Lang { Lang::from_code("it").expect("it registered") } +fn ru() -> Lang { + Lang::from_code("ru").expect("ru registered") +} + /// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like /// English, and reads the same in the menus and in the panels. #[test] @@ -597,6 +601,30 @@ fn italian_reads_as_italian() { assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); } +/// Russian uses the vector-illustration vocabulary its users know, has the three plural forms +/// (one/few/many), and reads the same in the menus and in the panels. +#[test] +fn russian_reads_as_russian() { + for (en, want) in [ + ("Artboard Tool", "Инструмент «Монтажная область»"), + ("Swatches", "Образцы"), + ("Pathfinder", "Обработка контуров"), + ("Stroke", "Обводка"), + ("Fill", "Заливка"), + ("Direct Selection Tool", "Инструмент «Прямое выделение»"), + ("Save As…", "Сохранить как…"), + ("Undo", "Отменить"), + ] { + assert_eq!(tr(ru(), en), want); + } + assert_eq!(trn(ru(), 1, "{n} Layer", "{n} Layers"), "1 слой"); + assert_eq!(trn(ru(), 2, "{n} Layer", "{n} Layers"), "2 слоя"); + assert_eq!(trn(ru(), 5, "{n} Layer", "{n} Layers"), "5 слоёв"); + assert_eq!(trn(ru(), 11, "{n} Layer", "{n} Layers"), "11 слоёв"); + assert_eq!(trn(ru(), 21, "{n} Layer", "{n} Layers"), "21 слой"); + assert_eq!(trn(ru(), 22, "{n} Layer", "{n} Layers"), "22 слоя"); +} + /// Catalogs written with spaces between words keep a fragment's leading and trailing spaces: the /// hint bar and a few labels are joined from pieces (" to finish", "Press "). #[test] @@ -615,6 +643,12 @@ fn czech_plurals_have_three_forms() { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2]); } +#[test] +fn russian_plurals_have_three_forms() { + let forms: Vec = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_russian).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]); +} + /// Czech, Spanish and Italian letters (and the punctuation their text uses) come from each family's own first font, not /// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the /// face that draws missing glyphs, the first one, as missing.) @@ -638,6 +672,28 @@ fn czech_spanish_and_italian_glyphs_are_available_without_system_fonts() { }); } +/// Russian letters (and the punctuation the catalog uses) come from each family's own first font, not +/// from a fallback further down the stack. +#[test] +fn russian_glyphs_are_available_without_system_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect(); + for (family, first) in families { + let first = first.unwrap(); + let mut font = fonts.fonts.font(&family); + let chars = font.characters(); + for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ«»„“…–’".chars() + { + assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}"); + } + } + }); +} + /// A language chosen in an older version (saved with the UI state as `"language"`) carries over to /// the `interfaceLanguage` preference, and isn't written back. #[test] @@ -708,7 +764,7 @@ fn complete_languages_translate_every_message() { } /// Languages whose catalogs cover every status and error message. -const COMPLETE_MESSAGES: &[&str] = &["es", "it"]; +const COMPLETE_MESSAGES: &[&str] = &["es", "it", "ru"]; /// 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 54aa028e..1cedaaea 100644 --- a/docs/development.md +++ b/docs/development.md @@ -135,7 +135,8 @@ in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it, so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), Italian (`it`, -complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian Portuguese +complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*` +locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese (`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added. - `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a @@ -163,10 +164,10 @@ complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Czech ( 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 and Italian cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, Italian and Russian 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` - and an `it.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui + `COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`, + an `it.tsv` and a `ru.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 ba93f62bcb954b015a5d614fbb4e288dd9267085 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:48:27 +0700 Subject: [PATCH 22/74] Fix #577: the gradient stop popover stays open after the click that opens it The Gradient panel's colour chip opens the stop popover on a click. The popover closes on a click elsewhere, and the opening click itself, which is released in the frame the popover first draws and lies outside it, counted as one: the popover closed at once (a double-click got through, as the popover lets the double-click that opens it from the canvas pass). The popover now records the frame it first drew in and only a click in a later frame closes it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs/gradient_stop.rs | 50 ++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b4c4fa7..4e3852de 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); 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), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 4d102f33..7282415a 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -29,6 +29,9 @@ use crate::widgets::{self, Live}; pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm); +/// The field holding the frame the popover was first drawn in. +const OPENED: &str = "__opened"; + /// What the popover edits: the selected stop of the gradient behind the active proxy (with all /// its stops), or the selected point of a freeform gradient. enum Target { @@ -131,12 +134,18 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); }) .response; - if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) { - d.fields.insert("tab".into(), json!(tab)); + // The frame it opened in (its first drawn). + let frame = ctx.cumulative_frame_nr(); + let opened = d.fields.get(OPENED).and_then(Value::as_u64).unwrap_or(frame); + if let Some(d) = app.ui.dialog.as_mut() { + if let Some(tab) = tab { + d.fields.insert("tab".into(), json!(tab)); + } + d.fields.insert(OPENED.into(), json!(opened)); } - // The double-click that opened the popover lands on its chip, outside it: only a later click - // elsewhere closes it. - if resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) { + // The click that opened the popover (the Gradient panel's colour chip) or the double-click + // (a stop's chip) lands outside it: only a later click elsewhere closes it (#577). + if frame > opened && resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) { app.ui.dialog = None; } } @@ -246,10 +255,18 @@ mod tests { } } + thread_local! { + // One context per test, so frames follow one another as in the app. + static CTX: egui::Context = { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx + }; + } + /// One headless frame of the dialog layer and the shortcuts. fn frame(app: &mut VectorcraftApp, events: Vec) { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); + let ctx = CTX.with(Clone::clone); let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { crate::shortcuts::handle(app, ui.ctx()); super::super::show(app, ui.ctx()); @@ -312,6 +329,25 @@ mod tests { assert!(app.ui.dialog.is_none(), "a click elsewhere closes it"); } + /// #577: the Gradient panel's colour chip opens it with a click whose release lands in the + /// frame it opens in, outside it: that click doesn't close it. + #[test] + fn the_click_that_opens_it_from_the_panel_keeps_it_open() { + let mut app = app(); + let at = egui::pos2(5.0, 5.0); + frame(&mut app, vec![egui::Event::PointerMoved(at), click(at, true)]); + // The panel opens it while the click is released. + app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "screen": [200.0, 200.0], "tab": "color"}))); + frame(&mut app, vec![click(at, false)]); + for _ in 0..3 { + frame(&mut app, vec![]); + } + assert!(app.ui.dialog.is_some(), "the opening click must not close it"); + let away = egui::pos2(5.0, 600.0); + frame(&mut app, vec![egui::Event::PointerMoved(away), click(away, true), click(away, false)]); + assert!(app.ui.dialog.is_none(), "a later click elsewhere closes it"); + } + #[test] fn the_popover_closes_when_its_stop_goes_away() { let mut app = app(); From b771a743619901e3962418f4e44b953009260f16 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:52:15 +0700 Subject: [PATCH 23/74] =?UTF-8?q?Russian:=20H=20is=20=D0=92=20(=D0=B2?= =?UTF-8?q?=D1=8B=D1=81=D0=BE=D1=82=D0=B0),=20not=20=D0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ru.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a3853014..53cafffa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1071,7 +1071,7 @@ Guides Направляющие Guides & Grid Направляющие и сетка H Spacing: Интервал по горизонтали: - H: Ш: + H: В: Hair Space Волосяная шпация Hand Tool Инструмент «Рука» Hand-Drawn Нарисованный от руки From 5a09d1aef85f265d57fc2c2be0194e3026f0fe92 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 03:56:10 +0700 Subject: [PATCH 24/74] Type: ligatures drop past -20 or +55 tracking, as Adobe's composers do #583 kept ligatures within +-50/1000 em. Adobe's composers are described as dropping them past about -8% and +22% of the word space (John Hudson, www-style, 2012-07): about -20 and +55 for a typical 250/1000 em space. Condensed type shows a fixed ligature sooner than expanded type does. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/text/src/features.rs | 23 +++++++++++++---------- crates/text/src/lib.rs | 2 +- crates/text/src/tests_typo.rs | 6 ++++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bf9d85b0..962fb51b 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index ef446dde..1cf82630 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -3,25 +3,27 @@ use harfrust::{Feature, Tag}; use vectorcraft_doc::CharStyle; -/// Tracking (in 1/1000 em) beyond which standard ligatures are dropped automatically. +/// Tracking (in 1/1000 em) outside which standard ligatures are dropped automatically: tighter +/// than the first, looser than the second. /// /// Letterspaced type should not ligate: an `fi` glyph keeps its letters tight while everything -/// around it opens up, which reads as a blot. Small tracking adjustments made to fit a line -/// (a few thousandths either way) are not letterspacing and keep the ligatures, so only -/// `|tracking|` above this threshold suppresses them. An explicit `liga` / `clig` in the -/// character's own features always wins. -pub const LIGATURE_TRACKING_LIMIT: f64 = 50.0; +/// around it opens up (or stays open while it closes up), which reads as a blot. Small tracking +/// adjustments made to fit a line are not letterspacing and keep the ligatures. The limits are +/// about −8% and +22% of a typical word space (250/1000 em), the thresholds Adobe's composers are +/// described as using (John Hudson, www-style, 2012-07): condensing shows the fixed ligature +/// sooner than expanding. An explicit `liga` / `clig` in the character's own features always wins. +pub const LIGATURE_TRACKING_LIMITS: (f64, f64) = (-20.0, 55.0); /// OpenType features applied during shaping. Kerning, `case` and ligature suppression are driven /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. -/// Standard ligatures are dropped when `|tracking|` exceeds [`LIGATURE_TRACKING_LIMIT`], unless +/// Standard ligatures are dropped when tracking is outside [`LIGATURE_TRACKING_LIMITS`], unless /// the character's features turn `liga` (or `clig`) on explicitly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OtFeatures { /// Vertical glyph alternates (`vert`), set by the writing direction. pub vertical: bool, - /// Standard ligatures (`liga`, `clig`). Suppressed automatically when `|tracking|` exceeds - /// [`LIGATURE_TRACKING_LIMIT`], unless the character turns them on explicitly. + /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is outside + /// [`LIGATURE_TRACKING_LIMITS`], unless the character turns them on explicitly. pub ligatures: bool, /// Contextual alternates (`calt`). pub contextual: bool, @@ -165,7 +167,8 @@ impl OtFeatures { /// Does `tracking` (1/1000 em) count as letterspacing that drops standard ligatures? pub fn ligatures_suppressed_by(tracking: f64) -> bool { // A non-finite value is not a real letterspacing request; keep the default. - tracking.is_finite() && tracking.abs() > LIGATURE_TRACKING_LIMIT + let (tight, loose) = LIGATURE_TRACKING_LIMITS; + tracking.is_finite() && (tracking < tight || tracking > loose) } /// Do `features` turn standard ligatures on explicitly (`liga`, `+liga`, `clig`, `+clig`)? The last diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index cb79ed0f..83e79b2c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -23,7 +23,7 @@ pub mod test_fonts; pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; -pub use features::{LIGATURE_TRACKING_LIMIT, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; +pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index a486224f..c3e1f7f1 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -377,9 +377,11 @@ fn opentype_ligature_switches() { // Small tracking adjustments (fitting a line) keep the ligature. assert_eq!(fi(-5.0, &[]), 1, "tracking -5 keeps fi"); assert_eq!(fi(20.0, &[]), 1, "tracking +20 keeps fi"); - assert_eq!(fi(LIGATURE_TRACKING_LIMIT, &[]), 1, "the limit itself keeps fi"); - // Real letterspacing suppresses ligatures (as letterspaced type should)... + let (tight, loose) = LIGATURE_TRACKING_LIMITS; + assert_eq!((fi(tight, &[]), fi(loose, &[])), (1, 1), "the limits themselves keep fi"); + // Real letterspacing suppresses ligatures (as letterspaced type should), condensing sooner... assert_eq!(fi(100.0, &[]), 2, "tracking 100 drops fi"); + assert_eq!(fi(-30.0, &[]), 2, "tracking -30 drops fi"); assert_eq!(fi(-100.0, &[]), 2, "tracking -100 drops fi"); // ...unless the character turns them on explicitly. assert_eq!(fi(100.0, &["liga"]), 1, "explicit liga wins over tracking"); From 2e64786e8cfe19d90344f73877b883e0382b89ed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:02:44 +0700 Subject: [PATCH 25/74] Fix #578: a drag on the Color panel's spectrum no longer flickers near white The Color panel shows the out-of-gamut and out-of-web warnings under its proxy only when the colour needs them, so the panel grew and shrank with the colour. Dragging on the spectrum's pale top edge, a colour out of gamut added a warning, the spectrum moved down under the pointer, the pointer then picked white (above the spectrum), the warning went away, the spectrum moved back up: the colour flipped every frame. The panel now keeps room for the warnings its mode can show, so its height no longer depends on the colour. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/panels/color.rs | 62 ++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ba8f6451..3c0c1af0 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); 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 Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index e1a13027..5b415279 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -503,21 +503,33 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut new: Option<(Color, Live, Vec)> = None; let mut behind: Option<(Color, Live)> = None; let mut tinted: Option<(f32, Live)> = None; - let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c)); + // The warnings the mode shows: out of gamut in RGB and HSB, out of web colours but in Web Safe + // RGB and Grayscale. + let (shows_gamut, shows_web) = (matches!(mode, Mode::Rgb | Mode::Hsb), !matches!(mode, Mode::WebSafe | Mode::Grayscale)); + let gamut = color.filter(|_| shows_gamut).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c)); ui.horizontal(|ui| { // Left column: proxy, out-of-gamut and out-of-web warnings. ui.vertical(|ui| { ui.set_width(44.0); super::proxy(app, ui, 40.0); - let web = color.filter(|c| !matches!(mode, Mode::WebSafe | Mode::Grayscale) && !is_web_safe(c)).map(|c| web_safe(&c)); - for (warning, fix) in [(GAMUT_WARNING, gamut), (WEB_WARNING, web)] { - if let Some(fix) = fix { - ui.add_space(6.0); - if warning_chip(ui, warning, &fix) { - new = Some((fix, Live::Released, components(mode, &fix))); - } + let web = color.filter(|c| shows_web && !is_web_safe(c)).map(|c| web_safe(&c)); + let warnings: Vec<_> = [(GAMUT_WARNING, gamut), (WEB_WARNING, web)].into_iter().filter_map(|(w, fix)| Some((w, fix?))).collect(); + for (warning, fix) in &warnings { + ui.add_space(6.0); + if warning_chip(ui, *warning, fix) { + new = Some((*fix, Live::Released, components(mode, fix))); } } + // Room for the mode's other warnings: the panel keeps its height as the colour changes, + // else the spectrum below moves under a dragging pointer and the colour flips every + // frame, e.g. between a pale colour out of gamut and white above the spectrum (#578). + for _ in warnings.len()..usize::from(shows_gamut) + usize::from(shows_web) { + ui.add_space(6.0); + ui.scope(|ui| { + ui.set_invisible(); + warning_chip(ui, WEB_WARNING, &Color::WHITE) + }); + } }); // Sliders (Shift-drag moves them in tandem). ui.vertical(|ui| { @@ -705,6 +717,40 @@ mod tests { (f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default()) } + #[test] + fn a_drag_on_the_spectrum_holds_still_near_white() { + let mut app = app(); + let ctx = egui::Context::default(); + let none = egui::Modifiers::NONE; + // White needs no warning, #fefefe the web one, a pale violet both: one panel height. + let heights: Vec = ["#ffffff", "#fefefe", "#ded3ff"] + .iter() + .map(|c| { + app.run("paint.setFill", json!({"color": c})).unwrap(); + frame(&mut app, &ctx, vec![], none); + frame(&mut app, &ctx, vec![], none).1 + }) + .collect(); + assert!(heights.windows(2).all(|w| w[0] == w[1]), "{heights:?}"); + // Drag along the spectrum's top edge (pale colours, some out of gamut), stopping at each + // point: the colour holds while the pointer does. + let r = frame(&mut app, &ctx, vec![], none).0.expect("the spectrum is drawn"); + let at = |x: f32| egui::pos2(r.left() + x * r.width(), r.top() + 2.0); + let button = |pressed| egui::Event::PointerButton { pos: at(0.02), button: egui::PointerButton::Primary, pressed, modifiers: none }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(0.02)), button(true)], none); + for i in 1..20 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at(i as f32 * 0.05))], none); + let fills: Vec = (0..3) + .map(|_| { + frame(&mut app, &ctx, vec![], none); + paints_hex(&app).0 + }) + .collect(); + assert!(fills.windows(2).all(|w| w[0] == w[1]), "at {}: {fills:?}", i as f32 * 0.05); + } + frame(&mut app, &ctx, vec![button(false)], none); + } + #[test] fn tandem_keeps_ratios() { let t = tandem(Mode::Rgb, &[100.0, 50.0, 25.0], 0, 200.0); From d2aad22dacc71936bebd6089fd64d511e591c8e3 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:26:47 -0700 Subject: [PATCH 26/74] M7: Every-line Composer for ragged text Illustrator's Every-line Composer balances the breaks of the whole paragraph for left/centre/right aligned text too, not only justified text. VectorCraft only composed justified area text; ragged text was always broken greedily. - Model: ParaStyle.composer (vectorcraft_doc::Composer, re-exported by vectorcraft-text), Every-line by default like Illustrator, saved only when Single-line. layout() reads it from the object; LayoutOptions.composer is now an Option override for tests. - Composer: the justified gate is gone. Ragged lines never shrink (a line that doesn't fit ends the node), badness is 100*r^3 with r = the line's leftover space over a rag zone of 1/6 of the width (finite for lines without spaces); the last line is free unless shorter than 1/3 of the width (measured in zones of 1/3), so a lone short word on the last line pulls a word down. Hyphens cost 25 in ragged text (50 in justified). Knuth-Plass fitness classes with adjacency demerits (justified 10000, ragged 1000). Lines past the last width change share one node key, active nodes are capped (4096) and paragraphs over 200k glyphs break single-line, so work stays bounded; ragged tries a tolerance of one rag zone, then any fitting breaks, then falls back to greedy. - text.setFormat {composer: "singleLine"|"everyLine"}; the Paragraph panel menu's composer items work with a check mark (Reset Panel goes back to Every-line). - Tests: ragged Every-line moves "or" down in "Destroy all creatures with toughness X or less." where greedy leaves "less." alone; the rag of long paragraphs is no worse than greedy (sum of squared leftover and of neighbour differences); Single-line is greedy; the 10k-char layout budget covers ragged text; command test with undo; native format round trip (rich_session's text is now Single-line). No existing test needed changing: the justified tests and hyphenated_layout_adds_hyphens (ragged, now Every-line) still pass. Auto alignment (start of the paragraph direction) is ragged; a wrapped line's Mojikumi flush opening bracket gives its half em back to the composer, as in the line loop; tests cover Auto in both directions. Layout of 10k chars, release: justified Every-line 5.6 ms, ragged Every-line 5.8 ms, ragged Single-line 4.8 ms. --- ROADMAP.md | 9 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/text.rs | 26 ++++ crates/engine/src/cmd/panelcmds.rs | 16 ++- crates/engine/src/tests_panelcmds.rs | 10 ++ crates/format/tests/native_v3.rs | 37 +++++- crates/testkit/src/fixtures.rs | 4 +- crates/text/src/composer.rs | 162 +++++++++++++++++++------ crates/text/src/layout.rs | 32 +++-- crates/text/src/lib.rs | 16 +-- crates/text/src/tests_typo.rs | 131 +++++++++++++++++--- crates/ui-egui/src/panels/paragraph.rs | 17 ++- docs/mcp.md | 14 +++ 13 files changed, 390 insertions(+), 88 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c35d4b20..8da77eb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. The largest single gap. 50–80 h. 4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, proportional vertical metrics, manual - tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a + tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line + composer choice is done), tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have initial support. 35–48 h across type core and advanced. 5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries @@ -138,7 +139,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), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. 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. 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. 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:** 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), Area Type Options (rows/columns/inset/first baseline), 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. 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. 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). - **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -175,7 +176,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -210,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| Type core | 9 | 79% | 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); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 | +| Type core | 9 | 79% | 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, hidden characters, 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e3b54a84..e19312f2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, - PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, + AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, + PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fb0d246e..fd1ba214 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -285,6 +285,24 @@ fn default_align_on() -> char { /// Distance between default tab stops when no explicit stop applies (½ inch). pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; +/// Paragraph composer (Paragraph panel menu): how lines are broken. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Composer { + /// Break each line as soon as it is full. + SingleLine, + /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word + /// spacing, ragged lines an even rag. + #[default] + EveryLine, +} + +impl Composer { + fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// Paragraph attributes (the Paragraph panel). /// /// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before @@ -299,6 +317,8 @@ pub struct ParaStyle { pub space_before: f64, pub space_after: f64, pub hyphenate: bool, + /// Line breaking: Single-line or Every-line Composer. + pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. pub tabs: Vec, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. @@ -341,6 +361,8 @@ struct ParaStyleFile { space_after: f64, #[serde(default)] hyphenate: bool, + #[serde(default, skip_serializing_if = "Composer::is_default")] + composer: Composer, #[serde(default, skip_serializing_if = "Vec::is_empty")] tabs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -370,6 +392,7 @@ impl From for ParaStyleFile { space_before, space_after, hyphenate, + composer, tabs, style_name, mojikumi, @@ -387,6 +410,7 @@ impl From for ParaStyleFile { space_before, space_after, hyphenate, + composer, tabs, style_name, mojikumi, @@ -407,6 +431,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + composer, tabs, style_name, mojikumi, @@ -423,6 +448,7 @@ impl From for ParaStyle { space_before, space_after, hyphenate, + composer, tabs, style_name, mojikumi, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 3cee0f4a..02d7d62e 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -2,7 +2,7 @@ //! Character/Paragraph attributes. use serde_json::{Value, json}; -use vectorcraft_doc::NodeKind; +use vectorcraft_doc::{Composer, NodeKind}; use vectorcraft_geom::{Rect, Vec2}; use super::*; @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\")}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default)}", has_doc, set_format ), @@ -219,10 +219,19 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "leadingModel", "charAlign", "burasagari", + "composer", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); } + let composer = match p.get("composer") { + None | Some(Value::Null) => None, + Some(v) => Some(match v.as_str() { + Some("singleLine") => Composer::SingleLine, + Some("everyLine") => Composer::EveryLine, + _ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")), + }), + }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; let char_align = super::textedit::char_align_param(p, C)?; let mojikumi = match p.get("mojikumi") { @@ -329,6 +338,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = burasagari { para.burasagari = v; } + if let Some(v) = composer { + para.composer = v; + } super::typecmd::refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index b2374343..1844a810 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -248,6 +248,16 @@ fn text_set_format() { let NodeKind::Text(t) = node(&s, id).kind else { panic!() }; assert_eq!(t.first_style().kerning, None); assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err()); + // Paragraph composer: Every-line by default, undoable. + assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine); + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap(); + let NodeKind::Text(t) = node(&s, id).kind else { panic!() }; + assert_eq!(t.para.composer, vectorcraft_doc::Composer::SingleLine); + assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "best"})).is_err()); + assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": 1})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = node(&s, id).kind else { panic!() }; + assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine); } #[test] diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 8ca0d21d..f9ec96a1 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -7,7 +7,7 @@ use std::io::Write as _; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{ColorProfiles, Document, ImageBlob, ImageObject, Node, NodeKind, OpacityMask, Symbol}; +use vectorcraft_doc::{ColorProfiles, Composer, Document, ImageBlob, ImageObject, Node, NodeKind, OpacityMask, Symbol}; use vectorcraft_format::{FormatError, SaveOptions, VERSION, is_compressed, load, preview, save, save_with, sniff}; use vectorcraft_geom::Affine; use vectorcraft_testkit::fixtures; @@ -111,6 +111,41 @@ fn keys_from_newer_versions_round_trip() { check_native_roundtrip(&d).unwrap(); } +/// The paragraph composer is saved only when it isn't the default (Every-line) and reads back. +#[test] +fn text_composer_round_trips() { + let d = rich_doc(); + let composers = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.para.composer); + } + }); + v + }; + assert_eq!(composers(&d), [Composer::SingleLine]); + let bytes = save(&d, false); + assert!(String::from_utf8_lossy(&bytes).contains("\"composer\":\"singleLine\"")); + assert_eq!(composers(&load(&bytes).unwrap()), [Composer::SingleLine]); + // Files without the key (older files, Every-line text) read as Every-line. + fn strip(v: &mut Value) { + match v { + Value::Object(m) => { + m.remove("composer"); + m.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut v = json_of(&bytes); + strip(&mut v); + let d2 = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(composers(&d2), [Composer::EveryLine]); + assert!(!String::from_utf8_lossy(&save(&d2, false)).contains("composer")); +} + /// A document as the first version wrote it (format name `drawcraft`, anchors as maps). const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}], "layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[ diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 23dd3d17..0e946feb 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -112,7 +112,9 @@ pub fn rich_session() -> Session { exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}})); let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350}))); exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"})); - let _ = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30}))); + let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30}))); + // A non-default paragraph composer, so format round trips cover it. + exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"})); exec(&mut s, "layer.new", json!({"name": "Top"})); let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0); let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0); diff --git a/crates/text/src/composer.rs b/crates/text/src/composer.rs index 03b594f8..cea1c5d4 100644 --- a/crates/text/src/composer.rs +++ b/crates/text/src/composer.rs @@ -1,9 +1,17 @@ -//! Every-line composer: a Knuth–Plass style total-fit line breaker for justified paragraphs. +//! Every-line composer: a Knuth–Plass style total-fit line breaker. //! -//! Boxes are the paragraph's glyphs, glue is its spaces (word spacing may stretch to 150% and -//! shrink to 80%), and penalties are break opportunities after hyphens/dashes/CJK and at -//! hyphenation points. The breaks minimise the sum of squared (badness + penalty) demerits over -//! the whole paragraph, so loose lines are traded for evenly spaced ones. +//! Boxes are the paragraph's glyphs, glue is its spaces, and penalties are break opportunities +//! after hyphens/dashes/CJK and at hyphenation points. The breaks minimise the sum of squared +//! (badness + penalty) demerits over the whole paragraph, plus a demerit when neighbouring lines +//! fall in fitness classes more than one apart (a tight line next to a loose one). +//! +//! - **Justified** lines: word spacing may stretch to 150% and shrink to 80%; the badness comes +//! from the stretch/shrink ratio, so loose lines are traded for evenly spaced ones. +//! - **Ragged** lines (left/centre/right aligned): spaces keep their width, so a line never +//! shrinks (it must fit). The badness comes from the line's leftover space measured against a +//! rag zone of a sixth of the line width, so the rag is even rather than full lines followed by +//! a short one, and hyphenating costs less. The last line costs nothing unless it is shorter +//! than a third of the width (a word or two left dangling). use crate::shape::SGlyph; @@ -12,6 +20,21 @@ const SHRINK: f64 = 0.2; const HYPHEN_PENALTY: f64 = 50.0; const DOUBLE_HYPHEN_DEMERITS: f64 = 3000.0; const LINE_PENALTY: f64 = 10.0; +/// Demerits for adjacent lines whose fitness classes differ by more than one. +const ADJ_DEMERITS_JUSTIFIED: f64 = 10_000.0; +const ADJ_DEMERITS_RAGGED: f64 = 1_000.0; +/// Ragged lines only gain from hyphenation by filling the rag, so it costs less than in +/// justified text (where it also evens the word spacing). +const HYPHEN_PENALTY_RAGGED: f64 = 25.0; +/// Ragged text: leftover space is measured in units of this fraction of the line width. +const RAG_ZONE: f64 = 1.0 / 6.0; +/// Ragged text: a last line shorter than this fraction of the width is penalised, by how much +/// shorter it is in units of `SHORT_LAST_ZONE` of the width. +const SHORT_LAST: f64 = 1.0 / 3.0; +const SHORT_LAST_ZONE: f64 = 1.0 / 3.0; +/// Give up (fall back to the single-line composer) beyond this many active breakpoints. +const MAX_ACTIVE: usize = 4096; +const EPS: f64 = 1e-6; /// A place a line may end: glyph index `end` (exclusive), plus the width of the hyphen that is /// added when breaking there (0 = no hyphen). @@ -21,24 +44,56 @@ pub(crate) struct Breakpoint { pub hyphen: f64, } +/// How a paragraph is composed. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Params { + /// The last line is justified too (Justify All). + pub justify_last: bool, + /// Left/centre/right aligned text: spaces keep their width and lines are scored by their rag. + pub ragged: bool, + /// Maximum stretch ratio (justified) or leftover space in rag zones (ragged) of a line. + pub tolerance: f64, + /// Lines from this index on all have the same width, so their line numbers are interchangeable + /// (keeps the number of active breakpoints small). + pub uniform_from: usize, +} + struct Node { pos: usize, + /// Line number of the next line, clamped to `uniform_from`. line: usize, + fitness: u8, demerits: f64, hyphenated: bool, prev: Option, } +/// Fitness class 0 (tight) … 3 (very loose) of a line with adjustment ratio `r`. +fn fitness(r: f64, ragged: bool) -> u8 { + let bounds = if ragged { [0.15, 0.4, 0.75] } else { [-0.5, 0.5, 1.0] }; + if r < bounds[0] { + 0 + } else if r <= bounds[1] { + 1 + } else if r <= bounds[2] { + 2 + } else { + 3 + } +} + /// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`; -/// `cands` are the break opportunities in increasing order (the paragraph end is added). -/// `justify_last`: the last line is justified too (Justify All). Returns `(end, hyphen)` per line, -/// or `None` if no set of breaks fits within `tolerance` (maximum stretch ratio). +/// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets +/// an opening bracket there flush with the line's start); `cands` are the break opportunities +/// in increasing order (the paragraph end is added). +/// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance +/// (the caller then falls back to single-line breaking). pub(crate) fn compose( g: &[SGlyph], width: &dyn Fn(usize) -> f64, + start_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint], - justify_last: bool, - tolerance: f64, + p: &Params, ) -> Option> { let n = g.len(); if n == 0 { @@ -51,68 +106,93 @@ pub(crate) fn compose( w[i + 1] = w[i] + gl.adv; sp[i + 1] = sp[i] + if gl.is_space() { gl.adv } else { 0.0 }; } + if !w[n].is_finite() { + return None; + } // Trailing spaces are dropped at a break. let trim = |mut b: usize, a: usize| { - while b > a && g[b - 1].is_space() { + while b > a && g.get(b - 1).is_some_and(SGlyph::is_space) { b -= 1; } b }; let mut all: Vec = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect(); all.push(Breakpoint { end: n, hyphen: 0.0 }); - let mut nodes = vec![Node { pos: 0, line: 0, demerits: 0.0, hyphenated: false, prev: None }]; + let (adj_demerits, hyphen_penalty) = + if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) }; + let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphenated: false, prev: None }]; let mut active: Vec = vec![0]; for bp in &all { let last = bp.end == n; - let mut best: Vec<(usize, f64, usize)> = vec![]; // (line, demerits, from) + let mut best: Vec<(usize, u8, f64, usize)> = vec![]; // (line, fitness, demerits, from) let mut keep = Vec::with_capacity(active.len()); for &ai in &active { - let a = &nodes[ai]; + let Some(a) = nodes.get(ai) else { continue }; let t = trim(bp.end, a.pos); - let natural = w[t] - w[a.pos] + bp.hyphen; - let spaces = sp[t] - sp[a.pos]; + let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 }; + let natural = w[t] - w[a.pos] - credit + bp.hyphen; let lw = width(a.line); - let ratio = if natural < lw { - if last && !justify_last { - 0.0 - } else if spaces > 0.0 { - (lw - natural) / (spaces * STRETCH) - } else { - f64::INFINITY + // Adjustment ratio, badness and whether the line ends the active node's reach. + let ratio = if p.ragged { + if natural > lw + EPS { + // Ragged lines never shrink; longer lines from this node only get worse. + continue; + } + if last && !p.justify_last { + ((lw * SHORT_LAST - natural) / (lw * SHORT_LAST_ZONE).max(1.0)).max(0.0) + } else { + (lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0) } - } else if natural > lw { - if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY } } else { - 0.0 + let spaces = sp[t] - sp[a.pos]; + if natural < lw { + if last && !p.justify_last { + 0.0 + } else if spaces > 0.0 { + (lw - natural) / (spaces * STRETCH) + } else { + f64::INFINITY + } + } else if natural > lw { + if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY } + } else { + 0.0 + } }; if ratio < -1.0 { // Too long already; longer lines from this node only get worse. continue; } keep.push(ai); - if ratio > tolerance && !(last && ratio.is_infinite() && justify_last) { + let free_last = last && (p.ragged || !p.justify_last); + if ratio > p.tolerance && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) { continue; } let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 }; - let penalty = if bp.hyphen > 0.0 { HYPHEN_PENALTY } else { 0.0 }; + let penalty = if bp.hyphen > 0.0 { hyphen_penalty } else { 0.0 }; let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty; if bp.hyphen > 0.0 && a.hyphenated { d += DOUBLE_HYPHEN_DEMERITS; } + // A ragged last line's length says nothing about the rag: it keeps its neighbour's class. + let fit = if p.ragged && free_last { a.fitness } else { fitness(ratio, p.ragged) }; + if a.prev.is_some() && fit.abs_diff(a.fitness) > 1 { + d += adj_demerits; + } let total = a.demerits + d; - let line = a.line + 1; - match best.iter_mut().find(|b| b.0 == line) { - Some(b) if b.1 <= total => {} - Some(b) => *b = (line, total, ai), - None => best.push((line, total, ai)), + let line = (a.line + 1).min(p.uniform_from); + match best.iter_mut().find(|b| b.0 == line && b.1 == fit) { + Some(b) if b.2 <= total => {} + Some(b) => *b = (line, fit, total, ai), + None => best.push((line, fit, total, ai)), } } active = keep; - for (line, demerits, from) in best { - nodes.push(Node { pos: bp.end, line, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) }); + for (line, fitness, demerits, from) in best { + nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) }); active.push(nodes.len() - 1); } - if active.is_empty() { + if active.is_empty() || active.len() > MAX_ACTIVE { return None; } } @@ -120,10 +200,14 @@ pub(crate) fn compose( let mut out = vec![]; let mut cur = Some(end); while let Some(i) = cur { - if nodes[i].prev.is_some() { - out.push((nodes[i].pos, nodes[i].hyphenated)); + let nd = nodes.get(i)?; + if nd.prev.is_some() { + out.push((nd.pos, nd.hyphenated)); + } + cur = nd.prev; + if out.len() > n { + return None; } - cur = nodes[i].prev; } out.reverse(); Some(out) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cbd3e49b..752d23f8 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{ArcPath, PathData}; -use crate::composer::{Breakpoint, compose}; +use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics}; @@ -914,14 +914,18 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec { v } -/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform -/// line metrics); `None` falls back to the greedy single-line composer. -fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option> { - let justified = !matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); +/// Paragraphs longer than this (glyphs) are always broken line by line. +const MAX_COMPOSE_GLYPHS: usize = 200_000; + +/// Every-line composition of paragraph glyphs `sg` if applicable (area text with uniform line +/// metrics, justified or ragged); `None` falls back to the greedy single-line composer. +fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option> { + let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + let composer = cx.opts.composer.unwrap_or(para.composer); // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); - if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 || may_hang { + if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang { return None; } let m = Metrics::of(&sg[0]); @@ -948,9 +952,21 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> } let last = *widths.last()?; let width = |k: usize| widths.get(k).copied().unwrap_or(last); + // Lines from `uniform_from` on are interchangeable (same width). + let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1); let cands = candidates(cx.text, sg, para.hyphenate); let justify_last = para.justify == Justify::JustifyAll; - compose(sg, &width, &cands, justify_last, 1.0).or_else(|| compose(sg, &width, &cands, justify_last, 4.0)) + let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from }; + // Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on + // each line, then accept any lines that fit. + let (strict, loose) = if ragged { (1.0, f64::INFINITY) } else { (1.0, 4.0) }; + // The same rule as the line loop below: an opening bracket starting a wrapped line gives up + // the space before it. + let start_credit = |i: usize| { + let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; + sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) + }; + compose(sg, &width, &start_credit, &cands, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, ¶ms(loose))) } fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { @@ -985,7 +1001,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt pen.pending += para.space_before; } let n = sg.len(); - let composed = compose_para(cx, &sg, para, &pen); + let composed = compose_para(cx, &sg, para, &pen, rtl); let mut li_para = 0; let mut i = 0; loop { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 7833601a..bb8a5457 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -31,15 +31,8 @@ pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::FirstBaseline; -/// Paragraph composer (Paragraph panel menu). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Composer { - /// Break each line as soon as it is full. - SingleLine, - /// Knuth–Plass total fit over the paragraph (justified area text only). - #[default] - EveryLine, -} +/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`]. +pub use vectorcraft_doc::Composer; /// Layout parameters that the document model doesn't store per object (Area Type Options, /// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object. @@ -55,7 +48,8 @@ pub struct LayoutOptions { pub first_baseline: FirstBaseline, /// Minimum first-baseline offset in points. pub first_baseline_min: f64, - pub composer: Composer, + /// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`). + pub composer: Option, pub features: OtFeatures, } @@ -68,7 +62,7 @@ impl Default for LayoutOptions { inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, - composer: Composer::EveryLine, + composer: None, features: OtFeatures::default(), } } diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index dcd91800..55dcc9e3 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -227,7 +227,7 @@ fn composer_lines_never_exceed_frame() { for (hy, composer) in [(false, Composer::EveryLine), (true, Composer::EveryLine), (false, Composer::SingleLine), (true, Composer::SingleLine)] { let mut t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, 180.0, 2000.0), Justify::JustifyLeft); t.para.hyphenate = hy; - let l = layout_with(db(), &t, &LayoutOptions { composer, ..Default::default() }); + let l = layout_with(db(), &t, &LayoutOptions { composer: Some(composer), ..Default::default() }); assert!(l.lines.len() > 10); for (i, li) in l.lines.iter().enumerate() { assert!(li.x1 - li.x0 <= 180.0 + 1e-6, "{composer:?} hy={hy} line {i} too wide: {li:?}"); @@ -246,7 +246,7 @@ fn composer_lines_never_exceed_frame() { fn every_line_composer_is_more_even() { let t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, 190.0, 2000.0), Justify::JustifyLeft); let spread = |c: Composer| { - let l = layout_with(db(), &t, &LayoutOptions { composer: c, ..Default::default() }); + let l = layout_with(db(), &t, &LayoutOptions { composer: Some(c), ..Default::default() }); // Worst word-space stretch (space glyph advance) over all justified lines. let mut worst: f64 = 0.0; for li in &l.lines[..l.lines.len() - 1] { @@ -269,7 +269,7 @@ fn burasagari_keeps_the_every_line_composer_for_latin_text() { // A measure where the two composers break the copy differently. let mut t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, 300.0, 4000.0), Justify::JustifyLeft); let ends = |t: &TextObject, c: Composer| { - let l = layout_with(db(), t, &LayoutOptions { composer: c, ..Default::default() }); + let l = layout_with(db(), t, &LayoutOptions { composer: Some(c), ..Default::default() }); l.lines.iter().map(|li| li.glyph_end).collect::>() }; let every_line = ends(&t, Composer::EveryLine); @@ -278,6 +278,102 @@ fn burasagari_keeps_the_every_line_composer_for_latin_text() { assert_eq!(ends(&t, Composer::EveryLine), every_line); } +/// Width of `s` set on one line. +fn natural_width(s: &str, size: f64) -> f64 { + let l = layout(db(), &TextObject::point(Point::ZERO, s, style(size))); + l.lines[0].x1 - l.lines[0].x0 +} + +/// The text of each line. +fn line_texts(t: &TextObject, l: &TextLayout) -> Vec { + let text = t.plain_text(); + l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect() +} + +/// Ragged text: Every-line breaks before a word that would fit, to avoid leaving a lone short +/// word on the last line (single-line keeps filling the first line). Auto alignment is ragged +/// too, in either paragraph direction: set right to left, the lines break the same and stay +/// flush right. +#[test] +fn every_line_composer_balances_ragged_lines() { + use vectorcraft_doc::ParaDirection; + const S: &str = "Destroy all creatures with toughness X or less."; + let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0; + assert!(w < natural_width(S, 12.0), "the sentence needs two lines"); + let cases = [ + (Justify::Left, None), + (Justify::Center, None), + (Justify::Right, None), + (Justify::Auto, None), + (Justify::Auto, Some(ParaDirection::RightToLeft)), + (Justify::Left, Some(ParaDirection::RightToLeft)), + ]; + for (justify, direction) in cases { + let mut t = area(S, style(12.0), Rect::new(0.0, 0.0, w, 400.0), justify); + t.para.direction = direction; + t.para.composer = Composer::SingleLine; + let greedy = line_texts(&t, &layout(db(), &t)); + assert_eq!(greedy, ["Destroy all creatures with toughness X or", "less."], "{justify:?} {direction:?}"); + t.para.composer = Composer::EveryLine; + let every = line_texts(&t, &layout(db(), &t)); + assert_eq!(every, ["Destroy all creatures with toughness X", "or less."], "{justify:?} {direction:?}"); + // Burasagari (new type's Standard) leaves Latin text to the every-line composer. + t.para.burasagari = vectorcraft_doc::Burasagari::Standard; + assert_eq!(line_texts(&t, &layout(db(), &t)), every, "{justify:?} {direction:?} with burasagari"); + if (justify, direction) == (Justify::Auto, Some(ParaDirection::RightToLeft)) { + let l = layout(db(), &t); + assert!(l.lines.iter().all(|li| li.rtl && (li.x1 - w).abs() < 1e-6), "right to left Auto is flush right: {:?}", l.lines); + } + // The options override wins over the object's setting. + let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() }); + assert_eq!(line_texts(&t, &forced), greedy); + } +} + +/// Every-line evens the rag of a long ragged paragraph: the spread of the line ends is no worse +/// than greedy breaking, every line fits, and nothing is lost. +#[test] +fn every_line_composer_evens_the_rag() { + for (width, hy) in [(150.0, false), (190.0, false), (240.0, true), (310.0, false)] { + let mut t = area(©.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), Justify::Left); + t.para.hyphenate = hy; + let rag = |c: Composer| { + let mut t = t.clone(); + t.para.composer = c; + let l = layout(db(), &t); + assert!(!l.overflow); + for li in &l.lines { + assert!(li.x1 - li.x0 <= width + 1e-6, "{c:?} {width}: line too wide {li:?}"); + } + let clusters: std::collections::BTreeSet<(usize, usize)> = l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len)).collect(); + assert_eq!(clusters.iter().map(|c| c.1).sum::(), COPY.len() * 2, "every character placed"); + // Leftover space of all lines but the last: its sum of squares (how ragged) and the + // sum of squared differences between neighbours (how jagged). + let slack: Vec = l.lines[..l.lines.len() - 1].iter().map(|li| width - (li.x1 - li.x0)).collect(); + let jag: f64 = slack.windows(2).map(|p| (p[0] - p[1]).powi(2)).sum(); + (slack.iter().map(|s| s * s).sum::(), jag, l.lines.len()) + }; + let ((kp, kp_jag, kp_lines), (greedy, greedy_jag, greedy_lines)) = (rag(Composer::EveryLine), rag(Composer::SingleLine)); + assert!(kp <= greedy + 1e-6, "{width}: every-line rag {kp} vs greedy {greedy}"); + assert!(kp_jag <= greedy_jag + 1e-6, "{width}: every-line jag {kp_jag} vs greedy {greedy_jag}"); + assert!(kp_lines <= greedy_lines + 1, "{width}: {kp_lines} lines vs {greedy_lines}"); + } +} + +/// Single-line breaking is the greedy breaker: each line takes as many words as fit. +#[test] +fn single_line_composer_is_greedy() { + let width = 190.0; + let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, width, 2000.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let l = layout(db(), &t); + let lines = line_texts(&t, &l); + for pair in lines.windows(2) { + let next_word = pair[1].split(' ').next().unwrap(); + assert!(natural_width(&format!("{} {next_word}", pair[0]), 12.0) > width - 1e-6, "{pair:?}"); + } +} + #[test] fn hyphenation_rules() { assert_eq!(hyphen::hyphenate_word("typography"), "typo-gra-phy"); @@ -404,17 +500,22 @@ fn uncovered_characters_do_not_break_layout() { #[test] fn layout_10k_area_text_is_fast() { let text: String = COPY.chars().cycle().take(10_000).collect(); - let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), Justify::JustifyLeft); - t.para.hyphenate = true; - let _ = layout(db(), &t); - let n = 5; - let start = std::time::Instant::now(); - for _ in 0..n { - let l = layout(db(), &t); - assert!(!l.overflow); + for (justify, composer) in + [(Justify::JustifyLeft, Composer::EveryLine), (Justify::Left, Composer::EveryLine), (Justify::Left, Composer::SingleLine)] + { + let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), justify); + t.para.hyphenate = true; + t.para.composer = composer; + let _ = layout(db(), &t); + let n = 5; + let start = std::time::Instant::now(); + for _ in 0..n { + let l = layout(db(), &t); + assert!(!l.overflow); + } + let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64; + eprintln!("layout of 10k chars ({justify:?}, {composer:?}, hyphenated): {per:.3} ms"); + let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 }; + assert!(per < budget, "{justify:?} {composer:?}: {per} ms"); } - let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64; - eprintln!("layout of 10k chars (justified, hyphenated): {per:.3} ms"); - let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 }; - assert!(per < budget, "{per} ms"); } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 25ea8dbb..6788de17 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,9 +1,10 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents, -//! space before/after, Hyphenate and Mojikumi Set (with the East Asian options). +//! space before/after, Hyphenate and Mojikumi Set (with the East Asian options); the panel menu +//! picks the Single-line or Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Justify, NodeKind, ParaDirection, ParaStyle}; +use vectorcraft_doc::{Composer, Justify, NodeKind, ParaDirection, ParaStyle}; use super::character::text_style; use super::{pstate, set_pstate}; @@ -181,14 +182,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { }); } ui.separator(); - menu_item(ui, tl!("Single-line Composer"), false, false); - menu_item(ui, tl!("Every-line Composer"), false, true); + let composer = style.as_ref().map(|(_, p)| p.composer); + for (c, label, id) in + [(Composer::SingleLine, tl!("Single-line Composer"), "singleLine"), (Composer::EveryLine, tl!("Every-line Composer"), "everyLine")] + { + if menu_item(ui, label, has, composer == Some(c)) { + format(app, json!({ "composer": id })); + } + } ui.separator(); if menu_item(ui, tl!("Reset Panel"), has, false) { para_cmd(app, "text.setStyle", json!({"justify": "auto"})); format( app, - json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard"}), + json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "composer": "everyLine"}), ); } } diff --git a/docs/mcp.md b/docs/mcp.md index c029656d..5e57c32a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2549,6 +2549,20 @@ canvas as they export. `object.blend.expand` and `object.blend.release` keep the opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and `object.expand` with `object: true` expands the blends in the selection. +## Paragraph composer + +`text.setFormat {ids?, composer: "singleLine"|"everyLine"}` sets how a text object's lines are broken (the Paragraph +panel menu's Single-line and Every-line Composer; stored in the paragraph attributes as `para.composer`, saved only +when it is Single-line). Every-line, the default, picks the breaks of the whole paragraph together in area type: +justified text gets even word spacing, ragged text (left, centre or right aligned) an even rag, so it may move a word +that fits to the next line to avoid a lone short word on the last line or a full line next to a short one. +Single-line fills each line as far as it goes. Point type and type on a path have no line width, so both composers +give the same result there; paragraphs that mix type sizes fall back to Single-line. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[7],"composer":"singleLine"}}} +``` + ## Editing envelopes `object.envelope.editContents {editing}` switches between editing the envelope and its contents (the menu item reads From 5854346053a84adc7689d2bb282c9c053157e911 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:16:14 +0700 Subject: [PATCH 27/74] Fix #579: fonts activated by font services such as Adobe Fonts are listed on Windows Font services load their fonts with GDI at run time, from files in their own folders, without installing them: neither the font folders nor the registry list them, so the font scan never saw them. DirectWrite's system font collection holds them since Windows 10 1607. - vectorcraft-text: `set_platform_font_files` lets the app name font files the platform knows; each scan and each check for installed fonts asks again and reads those outside the font folders. A file named by itself is read whatever its extension (font services keep fonts in files without one). - The desktop app lists the local files of DirectWrite's system font collection on Windows (`system_fonts.rs`, through the windows crate already in the build), read in place. - The check for fonts installed while the app was in the background runs on a thread: DirectWrite's check for updates takes about 50 ms. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + ROADMAP.md | 2 +- apps/vectorcraft/Cargo.toml | 6 ++ apps/vectorcraft/src/main.rs | 5 ++ apps/vectorcraft/src/system_fonts.rs | 91 ++++++++++++++++++++++++++++ crates/text/src/fontdb.rs | 43 ++++++++++--- crates/text/src/lib.rs | 4 +- crates/text/src/tests_sysfonts.rs | 26 ++++++++ crates/ui-egui/src/lib.rs | 39 +++++++++++- docs/mcp.md | 3 +- 10 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 apps/vectorcraft/src/system_fonts.rs diff --git a/Cargo.lock b/Cargo.lock index e73ad92c..7334a36d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4312,8 +4312,10 @@ dependencies = [ "rfd", "serde_json", "vectorcraft-engine", + "vectorcraft-text", "vectorcraft-ui-egui", "webbrowser", + "windows", "winit", "winresource", ] diff --git a/ROADMAP.md b/ROADMAP.md index 962fb51b..c56a7e39 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); 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), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index b3f37d3a..dc9bf1c0 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -19,6 +19,7 @@ windows7 = ["eframe/glow"] [dependencies] vectorcraft-engine = { workspace = true } vectorcraft-ui-egui = { workspace = true } +vectorcraft-text = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } @@ -36,6 +37,11 @@ winit = { version = "0.30", default-features = false } [target.'cfg(windows)'.dependencies] clipboard-win = { workspace = 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. +[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } + [target.'cfg(target_os = "macos")'.dependencies] # The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK # with default features off). Pinned exactly: review the diff before bumping. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 9cf5c5fd..549a3ac7 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -27,6 +27,8 @@ mod mac_menu; #[cfg(target_os = "macos")] mod open_documents; mod printing; +#[cfg(all(windows, not(target_vendor = "win7")))] +mod system_fonts; mod window; use vectorcraft_engine::Session; @@ -314,6 +316,9 @@ fn main() -> std::process::ExitCode { // First, so every start-up warning is recorded (`logging`). let logger = logging::install(); vectorcraft_ui_egui::i18n::detect_system_lang_in_background(); + // 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(); 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/system_fonts.rs b/apps/vectorcraft/src/system_fonts.rs new file mode 100644 index 00000000..fc7eb38d --- /dev/null +++ b/apps/vectorcraft/src/system_fonts.rs @@ -0,0 +1,91 @@ +//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders +//! (`vectorcraft_text::set_platform_font_files`). +//! +//! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their +//! own folders, without installing them: the font folders and the registry don't list them. Since +//! Windows 10 (version 1607) DirectWrite's system font collection holds them too +//! (, +//! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other +//! apps do, reading the files in place (#579). +//! +//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance. +#![allow(unsafe_code)] + +use std::collections::BTreeSet; + +use windows::Win32::Graphics::DirectWrite::{ + DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader, +}; +use windows::core::Interface; + +/// The most fonts read from the collection (a system has a few thousand). +const MAX_FONTS: u32 = 1 << 16; +/// The longest file path taken, in UTF-16 units (Windows' own limit). +const MAX_PATH: u32 = 32_767; + +/// Have the font scans read the font files DirectWrite lists. +pub fn install() { + vectorcraft_text::set_platform_font_files(font_files); +} + +/// The local files of the fonts in DirectWrite's system font collection as it is now (fonts +/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the +/// font folders and the registry still list the installed fonts. +fn font_files() -> Vec { + match collection_files() { + Ok(files) => files.into_iter().collect(), + Err(e) => { + log::debug!("DirectWrite's system fonts aren't listed: {e}"); + Vec::new() + } + } +} + +fn collection_files() -> windows::core::Result> { + // SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the + // collection is asked to check for fonts loaded since the factory last looked. + let set = unsafe { + let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?; + let mut collection = None; + factory.GetSystemFontCollection(false, &mut collection, true)?; + let Some(collection) = collection else { return Ok(BTreeSet::new()) }; + collection.GetFontSet()? + }; + // SAFETY: a count of the set's fonts. + let count = unsafe { set.GetFontCount() }.min(MAX_FONTS); + // Faces of one file (a collection's, a variable font's instances) name it once. + Ok((0..count).filter_map(|i| local_path(&set, i)).collect()) +} + +/// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable +/// font, nor one in memory). +fn local_path(set: &IDWriteFontSet, i: u32) -> Option { + // SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while + // `file` (which owns it) lives, and is passed back with its own size; the path buffer is + // sized from DirectWrite's answer plus the terminating NUL. + unsafe { + let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?; + let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?; + let (mut key, mut size) = (std::ptr::null_mut(), 0u32); + file.GetReferenceKey(&mut key, &mut size).ok()?; + let len = loader.GetFilePathLengthFromKey(key, size).ok()?; + if len == 0 || len > MAX_PATH { + return None; + } + let mut path = vec![0u16; len as usize + 1]; + loader.GetFilePathFromKey(key, size, &mut path).ok()?; + Some(String::from_utf16_lossy(path.get(..len as usize)?)) + } +} + +#[cfg(test)] +mod tests { + /// DirectWrite lists the installed fonts, by their files' full paths. + #[test] + fn directwrite_lists_the_installed_font_files() { + let files = super::font_files(); + assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first()); + let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase(); + assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first()); + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 789eb90a..38f7663b 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -782,8 +782,29 @@ pub(crate) fn sfnt_of(tables: &[(&[u8; 4], &[u8])]) -> Option> { Some(font) } -/// The platform's font folders (the system's and the user's) and, on Windows, the font files -/// registered with it outside them, scanned by [`FontDb::global`]. +/// Lists the font files the platform's font service knows, for [`set_platform_font_files`]. +pub type PlatformFontFiles = fn() -> Vec; + +/// What lists the platform's font files, set by the app ([`set_platform_font_files`]). +#[cfg(not(target_arch = "wasm32"))] +static PLATFORM_FONT_FILES: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Have [`system_font_dirs`] add the font files `list` gives (full paths) outside the font +/// 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. +pub fn set_platform_font_files(list: PlatformFontFiles) { + #[cfg(not(target_arch = "wasm32"))] + // A second call keeps the first lister, as documented. + let _ = PLATFORM_FONT_FILES.set(list); + #[cfg(target_arch = "wasm32")] + let _ = list; +} + +/// 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`]. pub fn system_font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); if cfg!(target_arch = "wasm32") { @@ -824,6 +845,11 @@ 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)); } + #[cfg(not(target_arch = "wasm32"))] + if let Some(list) = PLATFORM_FONT_FILES.get() { + let files = fonts_outside(list(), &dirs); + dirs.extend(files); + } dirs } @@ -848,7 +874,7 @@ fn registered_font_files(font_dirs: &[PathBuf]) -> Vec { /// The full paths among `registered` (font registrations' data) that aren't in `font_dirs` /// (ignoring case, as Windows paths do), sorted and deduplicated. File names alone are files in /// the Windows font folder, which is scanned anyway. The count is capped. -#[cfg(any(windows, test))] +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn fonts_outside(registered: impl IntoIterator, font_dirs: &[PathBuf]) -> Vec { const MAX_REGISTERED: usize = 1 << 16; let lower = |p: &Path| PathBuf::from(p.to_string_lossy().to_lowercase()); @@ -1154,6 +1180,7 @@ impl FontDb { let mut n = 0; let paths = self.font_paths.get(); let mut modified_at = Vec::new(); + // Each file with whether it was named by itself rather than found in a folder. let mut files = Vec::new(); let mut stack = paths.clone(); // Each folder once, however links lead back to it. @@ -1166,7 +1193,7 @@ impl FontDb { modified_at.push((d.clone(), modified(&d))); // Not a folder: a font file named by itself (or nothing, yet). let Ok(rd) = std::fs::read_dir(&d) else { - files.push(d); + files.push((d, true)); continue; }; for e in rd.flatten() { @@ -1174,13 +1201,15 @@ impl FontDb { if p.is_dir() { stack.push(p); } else { - files.push(p); + files.push((p, false)); } } } - for p in files { + for (p, named) in files { + // 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. let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + if !named && !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { 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 83e79b2c..5a5b3d17 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -24,7 +24,9 @@ pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; -pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs}; +pub use fontdb::{ + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, PlatformFontFiles, set_platform_font_files, style_weight, system_font_dirs, +}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index 80ca563d..12f49a02 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -237,3 +237,29 @@ fn the_database_tells_when_fonts_were_installed_or_removed_since_its_scan() { assert_eq!(db.load_system_fonts(), 2); assert_eq!(db.styles(FAMILY), ["Bold"]); } + +/// A font a font service loads from a file without an extension, outside the font folders. +fn service_font() -> PathBuf { + std::env::temp_dir().join(format!("vc-sysfonts-{}-service", std::process::id())).join(".29457") +} + +/// The fonts a font service (Adobe Fonts on Windows, through DirectWrite) loads in place are +/// cataloged: named by the platform's lister, read whatever their file is called (#579). +#[test] +fn fonts_the_platform_lists_outside_the_font_folders_are_cataloged() { + let file = service_font(); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, renamed_to("SourceSans3-Regular.ttf", "Service Sans3")).unwrap(); + // The lister's files outside the font folders are scanned with them, each once (a collection + // lists its file once per face). + fn lister() -> Vec { + vec![service_font().to_string_lossy().into_owned(); 2] + } + set_platform_font_files(lister); + let dirs = system_font_dirs(); + assert_eq!(dirs.iter().filter(|d| **d == file).count(), 1, "{dirs:?}"); + // A file named by itself is read without a font's extension, one in a folder isn't. + let db = FontDb::with_font_dirs(vec![file.clone()]); + assert_eq!(db.styles("Service Sans3"), ["Regular"]); + assert!(!FontDb::with_font_dirs(vec![file.parent().unwrap().to_path_buf()]).has_family("Service Sans3")); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bf3ecb07..baa538ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -347,6 +347,9 @@ pub struct VectorcraftApp { /// The thread that checks the system clipboard for `system_paste` instead /// ([`Services::clipboard_probe`]). clipboard_probe: Option, + /// The look for fonts installed or removed while the app was in the background, running + /// ([`Self::refresh_installed_fonts`]): whether they were. + font_check: Option>, /// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]). pub(crate) paste_chord: shortcuts::PasteChord, /// Saves and exports running in the background (Preferences → File Handling). @@ -423,6 +426,7 @@ impl VectorcraftApp { system_paste: false, system_paste_at: f64::NEG_INFINITY, clipboard_probe: None, + font_check: None, paste_chord: Default::default(), background: Default::default(), recovery: Default::default(), @@ -785,6 +789,7 @@ impl VectorcraftApp { self.system_paste_at = now; self.system_paste = wanted && self.system_clipboard_pasteable(); } + self.poll_font_check(ctx); background::poll(self); if !self.background.jobs.is_empty() { // Keep the status bar's progress moving and pick the result up when it arrives. @@ -858,10 +863,38 @@ impl VectorcraftApp { /// Fonts installed or removed while the app was in the background are listed when it comes /// back (Refresh Font List by itself): a look at the font folders, a scan only when they changed. + /// The look runs on another thread (asking DirectWrite for the fonts font services loaded + /// meanwhile takes tens of milliseconds on Windows, #579), [`Self::poll_font_check`] scans. fn refresh_installed_fonts(&mut self) { - if vectorcraft_text::FontDb::global().installed_fonts_changed() { - // A failure shows in the status bar, as the menu item's does. - let _ = self.run("text.rescanFonts", json!({})); + if self.font_check.is_some() { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let look = move || { + // The UI gone meanwhile has nothing to refresh. + let _ = tx.send(vectorcraft_text::FontDb::global().installed_fonts_changed()); + }; + let spawned = + if cfg!(target_arch = "wasm32") { None } else { std::thread::Builder::new().name("font-check".into()).spawn(look.clone()).ok() }; + if spawned.is_none() { + look(); + } + self.font_check = Some(rx); + } + + /// Rescan the fonts once [`Self::refresh_installed_fonts`]'s look says they changed. + fn poll_font_check(&mut self, ctx: &egui::Context) { + let Some(check) = &self.font_check else { return }; + match check.try_recv() { + Ok(changed) => { + self.font_check = None; + if changed { + // A failure shows in the status bar, as the menu item's does. + let _ = self.run("text.rescanFonts", json!({})); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.font_check = None, } } diff --git a/docs/mcp.md b/docs/mcp.md index c029656d..ba949602 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2310,7 +2310,8 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web): 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; macOS: `/System/Library/Fonts`, `/Library/Fonts`, +registered outside them, such as fonts installed as shortcuts, and in the desktop app 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`, `/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 From d2bb95daab3eae9a3ceb321bcf45cf357960d4ca Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:12:10 -0700 Subject: [PATCH 28/74] M7: area type vertical alignment Area Type Options > Align: Top / Center / Bottom / Justify (AreaOptions.verticalAlign, LayoutOptions.vertical_align). Each row/column cell is aligned on its own; rectangular cells shift their lines, other frames and text wrap reflow until settled without losing text. Justify shares the leftover space equally between line gaps. Area Type Options shows Align and First Baseline as dropdowns (the parameter dialog's choices table). --- ROADMAP.md | 6 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/text.rs | 40 +++++- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/tests_typearea.rs | 35 +++++ crates/format/tests/native_v3.rs | 25 ++++ crates/svg/tests/text_layout.rs | 14 ++ crates/testkit/src/fixtures.rs | 8 ++ crates/text/src/layout.rs | 142 ++++++++++++++++++- crates/text/src/lib.rs | 7 +- crates/text/src/tests_typo.rs | 191 ++++++++++++++++++++++++++ crates/ui-egui/src/dialogs/command.rs | 49 +++++++ docs/mcp.md | 16 +++ 13 files changed, 524 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6e7067c8..5b1c0b75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,7 +139,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), Area Type Options (rows/columns/inset/first baseline), 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. 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. 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:** 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), 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. 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. 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). - **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -176,7 +176,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| Type core | 9 | 79% | 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, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | +| Type core | 9 | 79% | 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, 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e19312f2..b5650487 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -83,7 +83,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, - PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, + PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fd1ba214..f1c6a733 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -532,6 +532,34 @@ pub enum FirstBaseline { Fixed, } +/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalAlign { + /// Lines start at the top of the cell (the default). + #[default] + Top, + /// The block of lines is centred in the cell. + Center, + /// The last line's descent touches the cell bottom. + Bottom, + /// The first line stays at the top, the last one moves to the bottom and the space left over + /// is shared equally between the lines (no paragraph spacing limit). + Justify, +} + +impl VerticalAlign { + pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; + pub fn id(self) -> &'static str { + match self { + VerticalAlign::Top => "top", + VerticalAlign::Center => "center", + VerticalAlign::Bottom => "bottom", + VerticalAlign::Justify => "justify", + } + } +} + /// Text Wrap Options of a wrap object (Object → Text Wrap). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -634,11 +662,21 @@ pub struct AreaOptions { pub first_baseline: FirstBaseline, /// Minimum first-baseline offset in points. pub first_baseline_min: f64, + /// Vertical alignment of the lines in each row/column. + pub vertical_align: VerticalAlign, } impl Default for AreaOptions { fn default() -> Self { - Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 } + Self { + rows: 1, + columns: 1, + gutter: 18.0, + inset: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + vertical_align: VerticalAlign::Top, + } } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index d5a2eff3..4c62a5c5 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options", + "{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options", has_selection, area_options ), diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests_typearea.rs index cdbbcd03..946fcd88 100644 --- a/crates/engine/src/tests_typearea.rs +++ b/crates/engine/src/tests_typearea.rs @@ -339,3 +339,38 @@ fn the_type_widget_converts_point_and_area_type() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(text(&s, id), before); } + +#[test] +fn area_type_options_align_the_lines_vertically() { + use vectorcraft_doc::VerticalAlign; + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 200.0, 300.0, "Short story"); + sel(&mut s, &[id]); + assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["verticalAlign"], "top"); + let top = layout(&s, id).lines[0].baseline; + let before = text(&s, id).cached_bounds; + let n = undo_len(&s); + let r = s.execute("text.areaOptions", &json!({"verticalAlign": "bottom"})).unwrap(); + assert_eq!(r["verticalAlign"], "bottom"); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Bottom); + let l = layout(&s, id); + let last = l.lines.last().unwrap(); + assert!((last.baseline + last.descent - 300.0).abs() < 1e-6); + assert!(l.lines[0].baseline > top + 200.0); + assert_ne!(text(&s, id).cached_bounds, before, "the cached bounds follow the text"); + assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Area Type Options")); + s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap(); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center); + // Unknown values are refused and change nothing. + for bad in [json!({"verticalAlign": "middle"}), json!({"verticalAlign": 3}), json!({"verticalAlign": null})] { + assert!(s.execute("text.areaOptions", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), n + 2); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Top); + assert_eq!(layout(&s, id).lines[0].baseline, top); + assert_eq!(text(&s, id).cached_bounds, before); + assert!(cmd::find_command("text.areaOptions").unwrap().params.contains("verticalAlign?: top|center|bottom|justify")); +} diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index f9ec96a1..3b502347 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -43,6 +43,31 @@ fn opts(version: u32, compress: bool) -> SaveOptions { SaveOptions { version, compress, ..SaveOptions::default() } } +#[test] +fn area_type_vertical_alignment_round_trips() { + let d = rich_doc(); + let text = String::from_utf8(save(&d, false)).unwrap(); + assert!(text.contains("\"verticalAlign\":\"center\""), "the fixture's area type is centred"); + let back = load(text.as_bytes()).unwrap(); + assert_eq!(doc_json(&back), doc_json(&d)); + let area = |d: &Document| { + let mut found = None; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind + && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) + { + found = Some(t.area.vertical_align); + } + }); + found.unwrap() + }; + assert_eq!(area(&back), vectorcraft_doc::VerticalAlign::Center); + // Files from before the option open top-aligned. + let old = text.replacen("\"verticalAlign\":\"center\",", "", 1).replacen(",\"verticalAlign\":\"center\"", "", 1); + assert!(!old.contains("verticalAlign")); + assert_eq!(area(&load(old.as_bytes()).unwrap()), vectorcraft_doc::VerticalAlign::Top); +} + #[test] fn compressed_files_load() { let d = rich_doc(); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index 8f536303..b3dd00c2 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -68,6 +68,20 @@ fn wrapped_lines_get_one_tspan_each() { assert!(!export(&d, &ExportOptions::default()).contains("text-anchor")); } +#[test] +fn vertically_aligned_area_type_exports_where_it_is_drawn() { + let top = area_doc(vec![run("alpha beta", style(12.0))], 200.0, Justify::Left); + let mut centred = top.clone(); + let id = centred.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = centred.node_mut(id).map(|n| &mut n.kind) { + t.area.vertical_align = vectorcraft_doc::VerticalAlign::Center; + } + let y = |d: &Document| attr(tspans(&export(d, &ExportOptions::default()))[0], "y").unwrap(); + let (yt, yc) = (y(&top), y(¢red)); + // The 160 pt frame holds one 12 pt line: centring moves it down about (160 - 14.4) / 2. + assert!(yc - yt > 60.0 && yc - yt < 80.0, "{yt} → {yc}"); +} + #[test] fn rich_text_writes_spacing_shift_and_scale() { let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) }; diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 0e946feb..c6d83913 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -115,6 +115,14 @@ pub fn rich_session() -> Session { let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30}))); // A non-default paragraph composer, so format round trips cover it. exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"})); + // Area type with non-default Area Type Options (vertical alignment, inset). + let area = id_of(&exec( + &mut s, + "text.create", + json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}), + )); + select(&mut s, &[area]); + exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2})); exec(&mut s, "layer.new", json!({"name": "Top"})); let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0); let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 752d23f8..8cdb8afc 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -13,7 +13,7 @@ use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics}; -use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout}; +use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; const EPS: f64 = 1e-6; @@ -127,6 +127,7 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { inset: a.inset, first_baseline: a.first_baseline, first_baseline_min: a.first_baseline_min, + vertical_align: a.vertical_align, ..LayoutOptions::default() }; layout_with(db, t, &opts) @@ -183,9 +184,12 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay } else { t.wrap.clone() }; - let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap); + let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap); cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect(); flow(&mut cx, ¶s, &t.para, Some(®ions)); + if opts.vertical_align != VerticalAlign::Top { + align_vertically(&mut cx, ¶s, &t.para, &mut regions); + } } TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), } @@ -416,6 +420,125 @@ fn finish_bounds(out: &mut TextLayout) { out.bounds = b.unwrap_or_default(); } +/// Space left in each cell after flowing top-aligned: (space above the first line's ascent, space +/// below the last line's descent, number of distinct baselines). Cells without lines are `None`. +fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec> { + regions + .iter() + .enumerate() + .map(|(ri, r)| { + let mut top = f64::INFINITY; + let mut bottom = f64::NEG_INFINITY; + let mut baselines: Vec = vec![]; + for l in out.lines.iter().filter(|l| l.region == ri) { + top = top.min(l.baseline - l.ascent); + bottom = bottom.max(l.baseline + l.descent); + if !baselines.iter().any(|b| (b - l.baseline).abs() < 1e-6) { + baselines.push(l.baseline); + } + } + if baselines.is_empty() || !top.is_finite() || !bottom.is_finite() { + return None; + } + // Never negative: a full (or overflowing) cell stays where top alignment put it. + Some(((top - r.top()).max(0.0), (r.bottom() - bottom).max(0.0), baselines.len())) + }) + .collect() +} + +/// Area Type Options "Align" other than Top: move each cell's lines down. Rectangular cells +/// without text wrap shift their lines (centre: half the space left below the last line; +/// bottom: all of it; justify: the first line stays, each further line gets an equal share of it). +/// Other frames give lines different widths at different heights, so the text flows again with +/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a +/// pass that would push text out of the frame is undone and retried with half the step. +fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: &mut [Region]) { + let align = cx.opts.vertical_align; + let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()); + if plain { + let space = cell_space(&cx.out, regions); + // Each distinct baseline's index within its cell, for justify. + let mut seen: Vec> = vec![vec![]; regions.len()]; + let offs: Vec = cx + .out + .lines + .iter() + .map(|l| { + let Some(Some((_, below, n))) = space.get(l.region).copied() else { return 0.0 }; + match align { + VerticalAlign::Top => 0.0, + VerticalAlign::Center => below * 0.5, + VerticalAlign::Bottom => below, + VerticalAlign::Justify => { + let Some(s) = seen.get_mut(l.region) else { return 0.0 }; + let k = s.iter().position(|b| (b - l.baseline).abs() < 1e-6).unwrap_or_else(|| { + s.push(l.baseline); + s.len() - 1 + }); + if n > 1 { below * k as f64 / (n - 1) as f64 } else { 0.0 } + } + } + }) + .collect(); + for (l, d) in cx.out.lines.iter_mut().zip(&offs) { + l.baseline += d; + } + for g in &mut cx.out.glyphs { + let d = offs.get(g.line).copied().unwrap_or(0.0); + if d != 0.0 { + let m = Affine::translate((0.0, d)); + g.outline.apply_affine(m); + g.xf = m * g.xf; + g.origin.y += d; + } + } + return; + } + let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end); + // How much of the measured space a pass takes up: halved after a pass that lost text. + let mut step = 1.0; + for _ in 0..8 { + let space = cell_space(&cx.out, regions); + let mut moved = false; + let saved: Vec<(f64, f64)> = regions.iter().map(|r| (r.shift, r.gap)).collect(); + for (r, s) in regions.iter_mut().zip(&space) { + let Some((above, below, n)) = *s else { continue }; + let (shift, gap) = match align { + VerticalAlign::Top => (r.shift, r.gap), + VerticalAlign::Center => (r.shift + (below - above) * 0.5 * step, r.gap), + VerticalAlign::Bottom => (r.shift + below * step, r.gap), + VerticalAlign::Justify if n > 1 => (r.shift, r.gap + below * step / (n - 1) as f64), + VerticalAlign::Justify => (r.shift, r.gap), + }; + let (shift, gap) = (shift.clamp(0.0, r.cell.height().max(0.0)), gap.clamp(0.0, r.cell.height().max(0.0))); + if (shift - r.shift).abs() > 0.25 || (gap - r.gap).abs() > 0.01 { + moved = true; + } + r.shift = shift; + r.gap = gap; + } + if !moved { + return; + } + let before = laid_out(&cx.out); + let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false)); + flow(cx, paras, para, Some(regions)); + if laid_out(&cx.out) < before { + // Text no longer fits (lines got narrower, or flow around a wrap object): undo the + // pass and try a smaller step. + (cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev; + for (r, (shift, gap)) in regions.iter_mut().zip(saved) { + r.shift = shift; + r.gap = gap; + } + step *= 0.5; + if step < 0.1 { + return; + } + } + } +} + /// One cell of a flattened area-type frame (the whole frame, or one row/column of it). struct Region { /// The cell (frame bounds, or a grid cell of them). @@ -427,6 +550,10 @@ struct Region { rect: bool, /// Text Wrap shapes: (polygons, offset, invert). wraps: Vec<(Vec>, f64, bool)>, + /// Vertical alignment: how far below the top-aligned position the first line starts. + shift: f64, + /// Vertical justification: extra space added between consecutive lines. + gap: f64, } /// Flattened closed polygons of a path. @@ -529,7 +656,7 @@ impl Region { let x0 = bbox.x0 + c as f64 * (cw + gutter); let y0 = bbox.y0 + r as f64 * (rh + gutter); let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; - out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone() }); + out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 }); } } out @@ -674,9 +801,9 @@ impl Pen<'_> { loop { let r = regions.get(self.ri)?; let mut baseline = match self.prev { - None if self.model == LeadingModel::EmBoxTop => r.top() + est.top.max(self.fb_min), - None => r.top() + est.first_baseline(self.fb, self.fb_min), - Some(b) => self.next_baseline(b, est), + None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min), + None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min), + Some(b) => self.next_baseline(b, est) + r.gap, }; loop { if baseline + est.desc > r.bottom() + 0.01 { @@ -1205,6 +1332,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt glyph_start, glyph_end: cx.out.glyphs.len(), avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, + region: if regions.is_some() { pen.ri } else { 0 }, }); pen.settled(baseline, m); // Further spans of this line band share the settled baseline. @@ -1281,6 +1409,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path glyph_start: 0, glyph_end: 0, avail: (0.0, 0.0), + region: 0, }); return; } @@ -1365,6 +1494,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path glyph_start: 0, glyph_end: cx.out.glyphs.len(), avail: (0.0, ap.len()), + region: 0, }); } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 884050e8..a019232a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -31,7 +31,7 @@ use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; -pub use vectorcraft_doc::FirstBaseline; +pub use vectorcraft_doc::{FirstBaseline, VerticalAlign}; /// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`]. pub use vectorcraft_doc::Composer; @@ -50,6 +50,8 @@ pub struct LayoutOptions { pub first_baseline: FirstBaseline, /// Minimum first-baseline offset in points. pub first_baseline_min: f64, + /// Vertical alignment of the lines in each row/column (Area Type Options "Align"). + pub vertical_align: VerticalAlign, /// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`). pub composer: Option, pub features: OtFeatures, @@ -64,6 +66,7 @@ impl Default for LayoutOptions { inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, + vertical_align: VerticalAlign::Top, composer: None, features: OtFeatures::default(), } @@ -124,6 +127,8 @@ pub struct LineInfo { /// Horizontal span available to the line (frame span minus indents; the content extent for /// point type). Used for hit testing across columns. pub avail: (f64, f64), + /// Area type: index of the frame cell ([`TextLayout::frames`]) the line sits in (0 otherwise). + pub region: usize, } #[derive(Clone, Debug, Default)] diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index c8ceb5ff..9fe691a8 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -457,6 +457,197 @@ fn rows_and_columns_flow_in_order() { } } +// ---------- vertical alignment ---------- + +fn valign(a: VerticalAlign) -> LayoutOptions { + LayoutOptions { vertical_align: a, ..Default::default() } +} + +/// (space above the first line's ascent, space below the last line's descent) in `0..h`. +fn block_space(l: &TextLayout, h: f64) -> (f64, f64) { + let (first, last) = (l.lines.first().unwrap(), l.lines.last().unwrap()); + (first.baseline - first.ascent, h - (last.baseline + last.descent)) +} + +#[test] +fn vertical_align_center_bottom_and_justify_in_a_rectangle() { + let t = area("First line\nSecond line\nThird line", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left); + let top = layout(db(), &t); + let (above, below) = block_space(&top, 300.0); + assert!(above.abs() < 1e-6 && below > 200.0); + let center = layout_with(db(), &t, &valign(VerticalAlign::Center)); + let (a, b) = block_space(¢er, 300.0); + assert!((a - b).abs() < 1e-6 && a > 100.0, "{a} {b}"); + // The model field reaches layout(). + let mut tc = t.clone(); + tc.area.vertical_align = VerticalAlign::Center; + assert!((layout(db(), &tc).lines[0].baseline - center.lines[0].baseline).abs() < 1e-9); + // Lines, glyph origins, outlines and transforms move together. + let d = center.lines[0].baseline - top.lines[0].baseline; + for (g, h) in center.glyphs.iter().zip(&top.glyphs) { + assert!((g.origin.y - h.origin.y - d).abs() < 1e-9); + assert!((g.xf.translation().y - h.xf.translation().y - d).abs() < 1e-9); + if !h.outline.elements().is_empty() { + assert!((g.outline.bounding_box().y0 - h.outline.bounding_box().y0 - d).abs() < 1e-6); + } + } + assert!(center.bounds.y0 > top.bounds.y0 + 100.0); + let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom)); + let (a, b) = block_space(&bottom, 300.0); + assert!(b.abs() < 1e-6 && a > 200.0); + // Justify: the first line stays, the last one reaches the bottom, gaps are equal. + let just = layout_with(db(), &t, &valign(VerticalAlign::Justify)); + let (a, b) = block_space(&just, 300.0); + assert!(a.abs() < 1e-6 && b.abs() < 1e-6, "{a} {b}"); + let gaps: Vec = just.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect(); + assert!(gaps.len() == 2 && (gaps[0] - gaps[1]).abs() < 1e-6 && gaps[0] > 100.0); + // A single line justifies to the top. + let one = area("Alone", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left); + assert_eq!(layout_with(db(), &one, &valign(VerticalAlign::Justify)).lines[0].baseline, layout(db(), &one).lines[0].baseline); + // Inset: the space is measured inside it. + let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, vertical_align: VerticalAlign::Bottom, ..Default::default() }); + assert!(block_space(&inset, 290.0).1.abs() < 1e-6); +} + +#[test] +fn vertical_align_leaves_overflowing_text_where_it_is() { + let text = COPY.repeat(4); + let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left); + let top = layout(db(), &t); + assert!(top.overflow); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + let l = layout_with(db(), &t, &valign(a)); + assert!(l.overflow); + assert_eq!(l.lines.len(), top.lines.len()); + assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end); + // A full cell has less than a line of space left: the lines barely move. + assert!(l.lines[0].baseline - top.lines[0].baseline < top.lines[0].ascent + top.lines[0].descent); + assert!(block_space(&l, 100.0).1 >= -1e-6); + } +} + +#[test] +fn vertical_align_acts_on_each_column_on_its_own() { + // Enough text for column 1 and a few lines of column 2. + let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 420.0, 120.0), Justify::Left); + let opts = |a| LayoutOptions { columns: 2, gutter: 20.0, vertical_align: a, ..Default::default() }; + let top = layout_with(db(), &t, &opts(VerticalAlign::Top)); + let col = |l: &TextLayout, c: usize| l.lines.iter().filter(|li| li.region == c).cloned().collect::>(); + assert!(!top.overflow && !col(&top, 0).is_empty() && !col(&top, 1).is_empty()); + assert!(col(&top, 1).iter().all(|li| li.x0 >= 220.0 - 1e-6)); + let bottom = layout_with(db(), &t, &opts(VerticalAlign::Bottom)); + for c in 0..2 { + let last = col(&bottom, c).last().cloned().unwrap(); + assert!((last.baseline + last.descent - 120.0).abs() < 1e-6, "column {c} ends at the bottom"); + } + let shift = |c: usize| col(&bottom, c)[0].baseline - col(&top, c)[0].baseline; + assert!(shift(1) > shift(0) + 20.0, "the short column moves further: {} {}", shift(0), shift(1)); +} + +#[test] +fn vertical_align_in_a_circle_reflows_and_stays_inside() { + let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1); + let t = area_path("Centred text in a round frame flows again at its new height.", style(12.0), &circle, Justify::Left); + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + let l = layout_with(db(), &t, &valign(a)); + assert!(!l.overflow, "{a:?}"); + assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end); + let moved = if a == VerticalAlign::Justify { + l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline + } else { + l.lines[0].baseline - top.lines[0].baseline + }; + assert!(moved > 20.0, "{a:?} {moved}"); + for g in l.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) { + let b = g.outline.bounding_box(); + for p in [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)] { + assert!((p - Point::new(150.0, 150.0)).hypot() <= 121.0, "{a:?} {p:?}"); + } + } + } + let c = layout_with(db(), &t, &valign(VerticalAlign::Center)); + let (above, below) = block_space(&c, 270.0); + assert!((above - 30.0 - below).abs() < 10.0, "roughly centred: {above} {below}"); +} + +#[test] +fn vertical_align_with_text_wrap_flows_again_around_the_object() { + let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 400.0), Justify::Left); + let obstacle = Rect::new(100.0, 250.0, 200.0, 330.0).to_path(0.1); + t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&obstacle), wrap: Default::default() }]; + let top = layout(db(), &t); + let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom)); + assert!(!bottom.overflow); + assert_eq!(bottom.lines.last().unwrap().end, top.lines.last().unwrap().end); + let last = bottom.lines.last().unwrap(); + assert!(400.0 - (last.baseline + last.descent) < 15.0, "near the bottom: {}", last.baseline); + // Lines beside the object leave room for it (offset 6 pt). + for li in bottom.lines.iter().filter(|li| li.baseline + li.descent > 244.0 && li.baseline - li.ascent < 336.0) { + assert!(li.x1 <= 94.0 + 1e-6 || li.x0 >= 206.0 - 1e-6, "{li:?}"); + } +} + +#[test] +fn vertical_align_on_vertical_type_moves_columns_along_the_block_axis() { + // Vertical type: the block axis runs right to left, so "bottom" is the frame's left edge. + let mut t = area("Tate\nYoko", style(20.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left); + t.vertical = true; + let top = layout(db(), &t); + let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom)); + let center = layout_with(db(), &t, &valign(VerticalAlign::Center)); + assert!(top.bounds.x1 > 190.0 && top.bounds.x0 > 100.0, "{:?}", top.bounds); + assert!(bottom.bounds.x0.abs() < 0.5, "{:?}", bottom.bounds); + assert!((center.bounds.x0 - (200.0 - center.bounds.x1)).abs() < 0.5, "{:?}", center.bounds); + // Glyphs stay inside the frame; lines keep line-space coordinates. + for g in &bottom.glyphs { + assert!(g.origin.x >= -1e-6 && g.origin.x <= 200.0 + 1e-6); + } +} + +/// Top-to-Top leading (em box top to em box top): the first line hangs from the frame's top and a +/// line's leading is the space below it. Vertical alignment measures the space from the lines that +/// model placed, keeps their spacing when it shifts them, and justify adds its share on top of it. +#[test] +fn vertical_align_with_top_to_top_leading() { + use vectorcraft_doc::LeadingModel; + let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) }; + let mut t = area("", st(40.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left); + t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小\n中".into(), style: st(20.0) }]; + t.para.leading_model = LeadingModel::EmBoxTop; + let top = layout(db(), &t); + assert_eq!(top.lines.len(), 3); + assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline); + let gaps = |l: &TextLayout| l.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect::>(); + let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom)); + assert!(block_space(&bottom, 300.0).1.abs() < 1e-6, "{:?}", block_space(&bottom, 300.0)); + for (a, b) in gaps(&bottom).iter().zip(gaps(&top)) { + assert!((a - b).abs() < 1e-6, "spacing kept: {a} {b}"); + } + let center = layout_with(db(), &t, &valign(VerticalAlign::Center)); + let d = center.lines[0].baseline - top.lines[0].baseline; + assert!((d - block_space(&top, 300.0).1 * 0.5).abs() < 1e-6, "{d}"); + let just = layout_with(db(), &t, &valign(VerticalAlign::Justify)); + assert!((just.lines[0].baseline - top.lines[0].baseline).abs() < 1e-9, "the first line stays"); + assert!(block_space(&just, 300.0).1.abs() < 1e-6); + let extra: Vec = gaps(&just).iter().zip(gaps(&top)).map(|(a, b)| a - b).collect(); + assert!(extra.len() == 2 && (extra[0] - extra[1]).abs() < 1e-6 && extra[0] > 50.0, "{extra:?}"); + // A round frame reflows with Top-to-Top leading too, without losing text. + let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1); + let mut round = area_path("", st(20.0), &circle, Justify::Left); + round.runs = t.runs.clone(); + round.para.leading_model = LeadingModel::EmBoxTop; + let rtop = layout(db(), &round); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + let l = layout_with(db(), &round, &valign(a)); + assert!(!l.overflow, "{a:?}"); + assert_eq!(l.lines.last().unwrap().end, rtop.lines.last().unwrap().end); + // The 40 pt line starts low in the circle already, so centring moves it a little. + let want = if a == VerticalAlign::Center { 1.0 } else { 20.0 }; + assert!(l.lines.last().unwrap().baseline > rtop.lines.last().unwrap().baseline + want, "{a:?}"); + } +} + // ---------- OpenType ---------- #[test] diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 585092ce..52d6e211 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -38,6 +38,8 @@ fn choices(command: &str, key: &str) -> Option { match (command, key) { ("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS), ("type.pathOptions", "alignToPath") => Some(PATH_ALIGN), + ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), + ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), _ => None, } } @@ -45,6 +47,13 @@ fn choices(command: &str, key: &str) -> Option { /// Type on a Path Options › Align to Path. const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")]; +/// Area Type Options › First Baseline. +const FIRST_BASELINE: form::Choices = + &[("Ascent", "ascent"), ("Cap Height", "capHeight"), ("x Height", "xHeight"), ("Leading", "leading"), ("Fixed", "fixed")]; + +/// Area Type Options › Align (vertical alignment of the lines in each row/column). +const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")]; + /// Closes before running, so a dialog the command opens stays open. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); @@ -59,3 +68,43 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } app.run(&cmd, params) } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::{NodeKind, VerticalAlign}; + use vectorcraft_engine::Session; + + use super::*; + + #[test] + fn area_type_options_choices_match_the_params_doc() { + let doc = vectorcraft_engine::find_command("text.areaOptions").unwrap().params; + for key in ["firstBaseline", "verticalAlign"] { + let values: Vec<&str> = choices("text.areaOptions", key).unwrap().iter().map(|(_, v)| *v).collect(); + assert!(doc.contains(&format!("{key}?: {}", values.join("|"))), "{key}: {values:?}"); + } + assert!(choices("text.areaOptions", "firstBaselineMin").is_none(), "a distance, not a choice"); + assert!(choices("text.areaOptions", "inset").is_none()); + } + + #[test] + fn area_type_options_dialog_sets_the_vertical_alignment() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 20, "text": "Hello", "area": {"width": 200, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + crate::menus::invoke(&mut app, "text.areaOptions", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("verticalAlign"), "top"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("verticalAlign".into(), json!("center")); + crate::dialogs::confirm(&mut app).unwrap(); + let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.area.vertical_align, VerticalAlign::Center); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 24b71842..b3ab77bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2201,6 +2201,22 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane {"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} ``` +### Vertical alignment + +`text.areaOptions {verticalAlign: top|center|bottom|justify}` (Area Type Options › Align) places the lines of each +row/column cell on its own: `center` centres the block of lines in the cell, `bottom` puts the last line's descent on +the cell's bottom (inside the inset), `justify` keeps the first line at the top, moves the last one to the bottom and +shares the space left over equally between the lines (there is no paragraph spacing limit: every line gap grows by +the same amount). A cell with a single line justifies to the top, and a full or overflowing cell only moves by the +less-than-a-line of space it has left. In rectangular frames without text wrap the lines just move; in other +frames (and around wrap objects) the text flows again starting lower until it settles, never losing text that fit +top-aligned. Vertical type aligns along its block axis (`bottom` is the frame's left edge). Exports lay type out +again, so SVG, PDF and EPS show the aligned text. + +```json +{"name":"run_command","arguments":{"command":"text.areaOptions","params":{"verticalAlign":"center"}}} +``` + ## Converting between point type and area type With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside From 9e6c115481dad97a9cc04a8df6a108aff11eeac0 Mon Sep 17 00:00:00 2001 From: Emad Fussi <43019044+omd0@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:34:00 +0300 Subject: [PATCH 29/74] Fix RTL shaping across text runs --- contributors/people.toml | 3 + crates/text/src/layout.rs | 151 ++++++++++++++++++++++++++++++---- crates/text/src/shape.rs | 54 ++++++++++-- crates/text/src/tests_bidi.rs | 108 ++++++++++++++++++++++-- 4 files changed, 291 insertions(+), 25 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 26f68cda..02f8742e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -28,3 +28,6 @@ sync_github_name = false [people.mo0kid] sync_github_name = true + +[people.omd0] +sync_github_name = true diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cbd3e49b..41701c3c 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -761,10 +761,15 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b let mut j = i; while j < g.len() { let gl = &g[j]; - if j > i && !gl.is_space() && x + gl.adv > width + EPS { + let mut cluster_end = j + 1; + while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) { + cluster_end += 1; + } + let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum(); + if j > i && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, j) && g.get(j + 1).is_none_or(|n| n.byte != gl.byte) { - let mut end = j + 1; + if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1) { + let mut end = cluster_end; while g.get(end).is_some_and(SGlyph::is_space) { end += 1; } @@ -772,17 +777,18 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b } break; } - x += gl.adv; - if gl.break_after() && kinsoku_allows(g, j) { - if gl.is_soft_hyphen() { - if x + hyphen_glyph(gl).adv <= width + EPS { - last_break = Some((j + 1, true)); + x += cluster_width; + let last = &g[cluster_end - 1]; + if last.break_after() && kinsoku_allows(g, cluster_end - 1) { + if last.is_soft_hyphen() { + if x + hyphen_glyph(last).adv <= width + EPS { + last_break = Some((cluster_end, true)); } } else { - last_break = Some((j + 1, false)); + last_break = Some((cluster_end, false)); } } - j += 1; + j = cluster_end; } if j >= g.len() { return (g.len(), false); @@ -805,14 +811,12 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b } } } - // Don't separate a cluster's glyphs. - let (mut end, hy) = match last_break { + // Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only + // cluster starts, so the resulting end always keeps each source cluster together. + let (end, hy) = match last_break { Some(b) if b.0 > i => b, _ => (j, false), }; - while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte { - end -= 1; - } (end, hy) } @@ -835,6 +839,123 @@ fn kinsoku_between(before: char, after: Option) -> bool { !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)) } +#[cfg(test)] +mod wrapping_tests { + use super::*; + use std::sync::Arc; + + fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { + // The face is only a required SGlyph field here; advances and cluster boundaries below are + // entirely synthetic, so the regressions don't depend on a particular font's shaping. + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + let db = DB.get_or_init(|| FontDb::with_font_dirs(vec![])); + let face = db.face("Source Sans 3", "Regular").expect("bundled test face"); + specs + .iter() + .map(|&(byte, len, adv, ch)| SGlyph { + face: Arc::clone(&face), + gid: 0, + byte, + len, + run: 0, + adv, + dx: 0.0, + dy: 0.0, + sx: 1.0, + sy: 1.0, + bshift: 0.0, + rotation: 0.0, + ascent: 8.0, + descent: 2.0, + leading: 0.0, + cap: 6.0, + xh: 4.0, + ch, + tcy: None, + lead: 0.0, + level: unicode_bidi::Level::ltr(), + }) + .collect() + } + + fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range { + let first = glyphs.get(start).expect("nonempty source range"); + let last = glyphs.get(end - 1).expect("nonempty source range"); + first.byte..last.byte + last.len + } + + #[test] + fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { + let text = "بَت"; + let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); + + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None); + + assert!(!hyphenated); + assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); + assert_eq!(source_range(&glyphs, 0, end), 0..4); + assert_eq!(glyphs.get(end).map(|g| g.byte), Some(4)); + } + + #[test] + fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { + let text = "بَتَث"; + let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); + + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None); + assert_eq!(first_end, 1); + assert_eq!(source_range(&glyphs, 0, first_end), 0..4); + + let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None); + + assert!(!hyphenated); + assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); + assert_eq!(source_range(&glyphs, first_end, second_end), 4..8); + assert_eq!(glyphs.get(second_end).map(|g| g.byte), Some(8)); + } + + #[test] + fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { + let db = FontDb::global(); + let Some(face) = db.face_covering('ب').filter(|face| face.covers('َ')) else { + // Arabic integration coverage depends on an installed font; synthetic tests above are + // the font-independent regression oracle. + return; + }; + let text = "بَت"; + let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let bidi = para_bidi(text, None); + let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels); + let mut shaped = Vec::new(); + shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &OtFeatures::default(), levels, &mut shaped); + let Some(first) = shaped.first() else { return }; + let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count(); + if first.byte != 0 || first_end < 2 || first_end >= shaped.len() { + return; + } + // Pick a narrow width that fits glyphs before one positive-advance glyph but not that next + // glyph. This ensures the old per-glyph loop would have returned inside the first cluster. + let mut prefix = shaped.first().map_or(0.0, |g| g.adv); + let width = (1..first_end).find_map(|k| { + let advance = shaped.get(k)?.adv; + if prefix > EPS && advance > EPS { + Some(prefix) + } else { + prefix += advance; + None + } + }); + let Some(width) = width else { return }; + + let mut object = TextObject::point(Point::ZERO, text, style); + object.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 100.0).to_path(0.1)) }; + let result = layout_with(db, &object, &LayoutOptions::default()); + + let next_cluster_byte = shaped.get(first_end).map(|g| g.byte); + assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte); + } +} + #[cfg(test)] #[test] fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_punctuation() { diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 98dd71f1..f14f9ed1 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -196,20 +196,43 @@ pub(crate) fn shape_range( levels: &[Level], out: &mut Vec, ) { + if text.get(range.clone()).is_none() || range.is_empty() { + return; + } let level_at = |i: usize| levels.get(i - range.start).copied().unwrap_or_else(Level::ltr); let output_start = out.len(); + + // Text runs also carry non-shaping attributes (fill, stroke, etc.). Coalesce adjacent equal + // styles so an editor split does not become an accidental OpenType shaping boundary. Keep + // original run spans: each emitted cluster is attributed by its source byte below. + let mut spans: Vec<(Range, &CharStyle, Range)> = Vec::new(); for (ri, (rr, st)) in runs.iter().enumerate() { let a = rr.start.max(range.start); let b = rr.end.min(range.end); - if a >= b { + if a >= b || text.get(a..b).is_none() { continue; } + if let Some((previous, previous_style, source_runs)) = spans.last_mut() + && previous.end == a + && *previous_style == *st + { + previous.end = b; + source_runs.end = ri + 1; + continue; + } + spans.push((a..b, *st, ri..ri + 1)); + } + + for (span, st, source_runs) in spans { + let a = span.start; + let b = span.end; let Some(primary) = db.face(&st.font_family, &st.font_style) 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; // Split into segments by font coverage (and case, for Small Caps). - let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false, level: level_at(a) }; + let group_output_start = out.len(); + let mut seg = Segment { range: a..a, run: source_runs.start, st, face: primary.clone(), small: false, level: level_at(a) }; let mut cache: Vec<(char, Arc)> = Vec::new(); let mut script = Script::Common; for (i, c) in text[a..b].char_indices() { @@ -233,7 +256,7 @@ pub(crate) fn shape_range( if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) { if i > seg.range.start { seg.range.end = i; - shape_segment(text, &seg, feats, out); + shape_segment(text, &range, &seg, feats, out); } seg = Segment { range: i..i, face, small, level, ..seg }; } @@ -243,7 +266,10 @@ pub(crate) fn shape_range( } if b > seg.range.start { seg.range.end = b; - shape_segment(text, &seg, feats, out); + shape_segment(text, &range, &seg, feats, out); + } + for glyph in &mut out[group_output_start..] { + glyph.run = source_run_at(runs, &source_runs, glyph.byte).unwrap_or(source_runs.start); } } // Right-to-left segments come out of the shaper in visual order: back to logical order for @@ -255,6 +281,15 @@ pub(crate) fn shape_range( } } +/// Index in the original run list containing `byte`, limited to one coalesced shaping span. +/// Source runs are ordered and non-overlapping; partitioning keeps attribution bounded when a +/// document contains many adjacent style runs. +fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: usize) -> Option { + let source = runs.get(span.clone())?; + let i = source.partition_point(|(range, _)| range.end <= byte); + source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i) +} + /// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and /// Bopomofo, shaped together (splitting them would cost a shaper call per change of script). fn shaping_script(c: char) -> Script { @@ -281,7 +316,7 @@ fn is_mark(c: char) -> bool { || matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}') } -fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { +fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFeatures, out: &mut Vec) { let Segment { range, run, st, face, small, level } = seg; let (range, run, small) = (range.clone(), *run, *small); let text_seg = &text[range.clone()]; @@ -320,6 +355,15 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec = feats.resolve(st); let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index b912affe..0f088423 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -1,12 +1,42 @@ //! Direction, source-cluster and editing regressions; no RTL font assets required. use super::*; use kurbo::{Point, Rect, Shape}; -use vectorcraft_doc::{CharStyle, TextKind}; +use std::sync::Arc; +use vectorcraft_doc::{CharStyle, TextKind, TextRun}; fn text_layout(s: &str) -> TextLayout { layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default())) } +fn arabic_test_face(test: &str) -> Option> { + let db = FontDb::global(); + let face = ["Geeza Pro", "Arial", "Noto Sans Arabic"] + .into_iter() + .filter_map(|name| db.face(name, "Regular")) + .find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا')); + if let Some(face) = &face { + let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); + eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} ({path})", face.family, face.style); + } else { + eprintln!("SKIPPED: {test}; no installed candidate font covers Arabic beh, lam, and alef"); + } + face +} + +fn arabic_style(face: &FontFace) -> CharStyle { + CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() } +} + +fn assert_same_shaped_glyphs(left: &TextLayout, right: &TextLayout) { + assert_eq!(left.glyphs.len(), right.glyphs.len()); + for (a, b) in left.glyphs.iter().zip(&right.glyphs) { + assert_eq!( + (a.gid, a.byte, a.len, a.font_id, a.advance, a.origin, a.xf, a.rtl), + (b.gid, b.byte, b.len, b.font_id, b.advance, b.origin, b.xf, b.rtl), + ); + } +} + #[test] fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() { for s in ["שלום", "مرحبا"] { @@ -85,10 +115,14 @@ fn hebrew_marks_stay_with_base_clusters() { fn arabic_contextual_forms_and_lam_alef_use_the_shaper() { let db = FontDb::global(); // Installed fonts are optional; no proprietary or test font is copied into the repo. - let face = - ["Geeza Pro", "Arial", "Noto Sans Arabic"].into_iter().filter_map(|name| db.face(name, "Regular")).find(|f| f.covers('ل') && f.covers('ا')); - let Some(face) = face else { return }; - let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() }; + let Some(face) = arabic_test_face("arabic_contextual_forms_and_lam_alef_use_the_shaper") else { return }; + let st = arabic_style(&face); + let pair = "لا"; + let lam_alef = layout(db, &TextObject::point(Point::ZERO, pair, st.clone())); + let ligature = lam_alef.glyphs.iter().find(|g| g.byte == 0 && g.len == pair.len()); + assert!(ligature.is_some(), "lam-alef must retain one cluster spanning both source letters: {lam_alef:?}"); + assert_ne!(ligature.unwrap().gid, face.glyph_for('ل'), "lam-alef must use a shaped glyph"); + for s in ["سلام", "שלום سلام"] { let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone())); let arabic: Vec<_> = l.glyphs.iter().filter(|g| s.get(g.byte..).is_some_and(|t| t.starts_with(['س', 'ل', 'ا', 'م']))).collect(); @@ -100,6 +134,70 @@ fn arabic_contextual_forms_and_lam_alef_use_the_shaper() { } } +#[test] +fn identical_style_arabic_run_splits_keep_joining_and_source_attribution() { + let db = FontDb::global(); + let Some(face) = arabic_test_face("identical_style_arabic_run_splits_keep_joining_and_source_attribution") else { return }; + let style = arabic_style(&face); + let text = "بب"; + + let mut single = TextObject::point(Point::ZERO, text, style.clone()); + single.runs = vec![TextRun { text: text.to_string(), style: style.clone() }]; + let mut split = TextObject::point(Point::ZERO, text, style.clone()); + split.runs = vec![TextRun { text: "ب".into(), style: style.clone() }, TextRun { text: "ب".into(), style }]; + + let single = layout(db, &single); + let split = layout(db, &split); + assert!(single.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "single run must exercise contextual beh forms"); + assert_same_shaped_glyphs(&single, &split); + assert_eq!(split.glyphs.len(), 2, "beh-beh should remain two source clusters"); + for glyph in &split.glyphs { + let expected_run = usize::from(glyph.byte >= "ب".len()); + assert_eq!(glyph.run, expected_run, "source byte {} belongs to run {expected_run}", glyph.byte); + } +} + +#[test] +fn different_style_arabic_boundary_receives_joining_context() { + let db = FontDb::global(); + let Some(face) = arabic_test_face("different_style_arabic_boundary_receives_joining_context") else { return }; + let style = arabic_style(&face); + let text = "بب"; + let joined = layout(db, &TextObject::point(Point::ZERO, text, style.clone())); + assert!(joined.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "reference must use contextual beh forms"); + + let mut split = TextObject::point(Point::ZERO, text, style.clone()); + split.runs = + vec![TextRun { text: "ب".into(), style: style.clone() }, TextRun { text: "ب".into(), style: CharStyle { size: style.size * 1.5, ..style } }]; + let split = layout(db, &split); + assert_eq!(joined.glyphs.len(), 2); + assert_eq!(split.glyphs.len(), 2); + for glyph in &joined.glyphs { + let actual = split.glyphs.iter().find(|other| other.byte == glyph.byte).expect("same source cluster"); + assert_eq!(actual.gid, glyph.gid, "joining form changed at style boundary for byte {}", glyph.byte); + assert_eq!(actual.len, glyph.len); + assert_eq!(actual.run, usize::from(glyph.byte >= "ب".len())); + } +} + +#[test] +fn shape_range_handles_empty_runs_and_selected_utf8_subranges() { + let db = FontDb::global(); + let style = CharStyle::default(); + let text = "Aب"; + let runs = [(0..text.len(), &style)]; + let mut glyphs = Vec::new(); + + shape::shape_range(db, text, 0..0, &runs, &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 0..text.len(), &[], &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 1..2, &runs, &OtFeatures::default(), &[], &mut glyphs); + assert!(glyphs.is_empty()); + + shape::shape_range(db, text, 1..text.len(), &runs, &OtFeatures::default(), &[], &mut glyphs); + assert!(!glyphs.is_empty()); + assert!(glyphs.iter().all(|g| (1..text.len()).contains(&g.byte))); +} + #[test] fn bidi_isolates_numbers_and_brackets_keep_valid_clusters() { for s in ["שלום (123)", "مرحبا (Rust 42)", "English \u{2067}שלום 123\u{2069} end"] { From e12ed1608ef02722825941d2b76e0b54addc1312 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:36:06 +0700 Subject: [PATCH 30/74] Tests: the composer round trip counts #600's area type in the rich fixture #587 and #600 landed together: the fixture's new area type (Every-line, the default) is a second text object the composer test now sees. Co-Authored-By: Claude Opus 5.5 --- crates/format/tests/native_v3.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 3b502347..5919897e 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -149,10 +149,11 @@ fn text_composer_round_trips() { }); v }; - assert_eq!(composers(&d), [Composer::SingleLine]); + // The point type set to Single-line, then the area type left at the default. + assert_eq!(composers(&d), [Composer::SingleLine, Composer::EveryLine]); let bytes = save(&d, false); assert!(String::from_utf8_lossy(&bytes).contains("\"composer\":\"singleLine\"")); - assert_eq!(composers(&load(&bytes).unwrap()), [Composer::SingleLine]); + assert_eq!(composers(&load(&bytes).unwrap()), [Composer::SingleLine, Composer::EveryLine]); // Files without the key (older files, Every-line text) read as Every-line. fn strip(v: &mut Value) { match v { @@ -167,7 +168,7 @@ fn text_composer_round_trips() { let mut v = json_of(&bytes); strip(&mut v); let d2 = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(composers(&d2), [Composer::EveryLine]); + assert_eq!(composers(&d2), [Composer::EveryLine, Composer::EveryLine]); assert!(!String::from_utf8_lossy(&save(&d2, false)).contains("composer")); } From 017c846889c274814e52ff1001d222eb93448a14 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:39:56 +0700 Subject: [PATCH 31/74] Tests: the top-to-top em box check needs the craft-fonts CJK faces Without craft-fonts the CJK text falls back to a system font with its own em box (Windows: 34.0 instead of 35.2 at 40 pt); the relative alignment checks hold either way. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tests_typo.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 9fe691a8..70fce4fe 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -617,7 +617,11 @@ fn vertical_align_with_top_to_top_leading() { t.para.leading_model = LeadingModel::EmBoxTop; let top = layout(db(), &t); assert_eq!(top.lines.len(), 3); - assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline); + // The em box top sits 0.88 em above the baseline in the craft-fonts CJK faces; a system + // fallback (no craft-fonts) has its own metrics, which the relative checks below don't need. + if !crate::CRAFT_FONTS.is_empty() { + assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline); + } let gaps = |l: &TextLayout| l.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect::>(); let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom)); assert!(block_space(&bottom, 300.0).1.abs() < 1e-6, "{:?}", block_space(&bottom, 300.0)); From 50bb36ebc2686dd581583eb31d47246db98578a8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:40:30 +0700 Subject: [PATCH 32/74] Part of #372: .ai files write layers and sublayers as PDF layers, hidden ones kept A `.ai` file's PDF part is what apps that don't read VectorCraft's native document open. It had no PDF layers: hidden layers and sublayers were left out of it and everything else was flat page content. - `.ai` files (Save As, exports, the CLI) write PDF layers unless `createLayers` is false (a PDF standard still decides for itself). - createLayers writes sublayers too, as optional content groups listed under their layer's in `/Order`, hidden ones off with their art. - A layer's form gets a box round the whole page instead of its art's bounds, which readers made a clipping group of, gathering a layer's art and its sublayers' in one group. - PDF import: art inside a group, clip or form that dissolves into its parent keeps the layer it was drawn in, so sublayers nested in their layer's content import as sublayers. - Save PDF: "Create PDF layers from layers and sublayers" (ja, es, it, pt-br, ru, zh-hans, zh-hant). Hidden objects (not layers) still aren't in the PDF part. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/pdf.rs | 4 +- crates/engine/src/cmd/fileio/save.rs | 9 +- .../engine/src/cmd/fileio/tests_pdflayers.rs | 32 +++++++ crates/pdf/src/export.rs | 28 +++--- crates/pdf/src/forms.rs | 89 ++++++++++++++++--- crates/pdf/src/import.rs | 24 +++-- crates/pdf/src/lib.rs | 2 +- crates/pdf/src/settings.rs | 4 +- crates/pdf/src/tests_layers.rs | 51 +++++++++++ crates/ui-egui/src/dialogs/save_pdf.rs | 2 +- crates/ui-egui/src/i18n/es.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 | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- docs/mcp.md | 8 +- 19 files changed, 223 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8f1c291d..12189a2c 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); 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), `.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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 409e5847..e02ad9ac 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -86,7 +86,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "createLayers", ty: "boolean", default: "false", - description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)", + description: "layers and sublayers as PDF layers (optional content groups with their visibility, print state and lock, sublayers listed under their layers; PDF 1.5 or later)", }, FormatOption { name: "flattenerPreset", @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index c20dbe7e..0f9d29e7 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -306,11 +306,16 @@ fn blank_pages(doc: &Document) -> Document { const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; /// The encoder's params for a `.ai` file, from a save's or an export's: a PDF of every artboard -/// with the PDF options, always carrying the native document; Use Compression compresses the -/// PDF's content. +/// with the PDF options, always carrying the native document, its layers and sublayers PDF layers +/// (hidden ones off) unless asked otherwise; Use Compression compresses the PDF's content. pub(super) fn ai_params(params: &mut Value) { let Some(o) = params.as_object_mut() else { return }; o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]); + // Apps that read the PDF part (not the native document) then find the layers, the hidden ones + // with their art (#372); a PDF standard decides for itself. + if o.get("standard").is_none_or(Value::is_null) { + o.entry("createLayers").or_insert(json!(true)); + } if let Some(c) = o.get("compress").and_then(Value::as_bool) { let compression = o.entry("compression").or_insert_with(|| json!({})); if let Some(m) = compression.as_object_mut() { diff --git a/crates/engine/src/cmd/fileio/tests_pdflayers.rs b/crates/engine/src/cmd/fileio/tests_pdflayers.rs index 7576bc61..a1e71a9e 100644 --- a/crates/engine/src/cmd/fileio/tests_pdflayers.rs +++ b/crates/engine/src/cmd/fileio/tests_pdflayers.rs @@ -59,3 +59,35 @@ fn export_pdf_overprint_is_preserved_or_discarded() { assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1); assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0); } + +/// A `.ai` file's PDF part, which apps that don't read the native document open, has the layers +/// and sublayers as PDF layers, the hidden ones with their art (#372). +#[test] +fn ai_files_write_their_layers_and_sublayers_as_pdf_layers() { + let mut s = session(); + let layer1 = s.execute("document.inspect", &json!({})).unwrap()["layers"][1]["id"].as_u64().unwrap(); + let sub = s.execute("layer.newSublayer", &json!({"parent": layer1, "name": "Sketch"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": sub})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 10, "y": 40, "width": 20, "height": 20})).unwrap(); + s.execute("layer.setProps", &json!({"id": sub, "visible": false})).unwrap(); + // Name, shown and what it holds (sublayers by name), top-level layers in paint order. + fn rows(layers: &[std::sync::Arc]) -> Vec<(String, bool, Vec)> { + layers + .iter() + .map(|l| { + let held = + l.children().into_iter().flatten().map(|c| c.name.clone().filter(|_| c.is_layer()).unwrap_or_else(|| "art".into())).collect(); + (l.name.clone().unwrap_or_default(), l.visible, held) + }) + .collect() + } + let v = s.execute("document.export", &json!({"format": "ai"})).unwrap(); + let back = vectorcraft_pdf::import(&b64(&v)).unwrap(); + let art = || "art".to_string(); + assert_eq!(rows(&back.layers), [("Layer 1".to_string(), true, vec![art(), "Sketch".into()]), ("Notes".to_string(), false, vec![art()])]); + let sketch = back.layers.first().and_then(|l| l.children()?.last().cloned()).unwrap(); + assert_eq!(rows(&[sketch]), [("Sketch".to_string(), false, vec![art()])]); + // Asked not to, it writes none. + let v = s.execute("document.export", &json!({"format": "ai", "createLayers": false})).unwrap(); + assert_eq!(vectorcraft_pdf::import(&b64(&v)).unwrap().layers.len(), 1); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 548c33b9..9390cdc4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -453,11 +453,13 @@ pub(crate) struct Exporter<'a> { /// The fonts real text is written in, by face id, with their units per em; `None` for faces /// that can't be embedded. fonts: HashMap>, - /// Top-level layers are drawn as forms marked for their optional content groups + /// Layers and sublayers are drawn as forms marked for their optional content groups /// ([`crate::forms`]). pub layers: bool, - /// A top-level layer's form is being drawn. - in_layer: bool, + /// Each layer's index in [`crate::forms::pdf_layers`], by address. + layer_index: HashMap, + /// The address of the layer whose form is being drawn. + in_layer: usize, /// Overprinting fills and strokes are drawn as forms marked to overprint. pub overprint: bool, /// An overprinting fill or stroke was drawn so. @@ -485,7 +487,8 @@ impl<'a> Exporter<'a> { outline_text: set.advanced.outline_text, fonts: HashMap::new(), layers: false, - in_layer: false, + layer_index: crate::forms::pdf_layers(doc).iter().enumerate().map(|(i, l)| (address(l.node), i)).collect(), + in_layer: 0, overprint: false, overprinted: false, } @@ -601,6 +604,11 @@ fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) } +/// A node's address, to tell the very node apart from equal ones. +fn address(n: &Node) -> usize { + std::ptr::from_ref(n).addr() +} + fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } @@ -885,15 +893,15 @@ impl Exporter<'_> { fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { if self.layers - && !self.in_layer - && let NodeKind::Layer { template: false, printable, .. } = n.kind + && self.in_layer != address(n) + && let NodeKind::Layer { printable, .. } = n.kind && (printable || self.non_printing) - && let Some(i) = self.doc.layers.iter().position(|l| std::ptr::eq(&**l, n)) + && let Some(&i) = self.layer_index.get(&address(n)) { - // A PDF layer: hidden layers are written too (their group is off). - self.in_layer = true; + // A PDF layer: hidden layers and sublayers are written too (their group is off). + let enclosing = std::mem::replace(&mut self.in_layer, address(n)); crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true)); - self.in_layer = false; + self.in_layer = enclosing; return; } if !force && !n.visible { diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs index b0730e78..60b91b33 100644 --- a/crates/pdf/src/forms.rs +++ b/crates/pdf/src/forms.rs @@ -11,7 +11,9 @@ use std::io::Read; use krilla::geom::PathBuilder; use krilla::graphic::Graphic; use krilla::surface::Surface; -use vectorcraft_doc::{Document, NodeKind}; +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, NodeKind}; use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans}; use crate::output::{deflate, text_string}; @@ -21,12 +23,36 @@ use crate::{PdfError, PdfSettings, Standard}; /// What a marked form is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mark { - /// The art of top-level layer `i` (`Document::layers`). + /// The art of layer `i` of [`pdf_layers`]. Layer(usize), /// A fill or stroke that overprints. Overprint, } +/// A layer written as a PDF layer (an optional content group). +pub(crate) struct PdfLayer<'a> { + pub node: &'a Node, + /// Its parent layer's index in [`pdf_layers`] (none for a top-level layer). + pub parent: Option, +} + +/// The layers written as PDF layers: the top-level layers and their sublayers, but template layers +/// (and what they hold), each before its sublayers, in paint order (bottom first). +pub(crate) fn pdf_layers(doc: &Document) -> Vec> { + fn add<'a>(nodes: &'a [Arc], parent: Option, out: &mut Vec>) { + for n in nodes { + if let NodeKind::Layer { template: false, children, .. } = &n.kind { + let i = out.len(); + out.push(PdfLayer { node: n, parent }); + add(children, Some(i), out); + } + } + } + let mut out = vec![]; + add(&doc.layers, None, &mut out); + out +} + /// The x coordinate of a layer mark's first point (its y is the layer index). const LAYER_X: i32 = -32000; /// The x coordinate of an overprint mark's first point. @@ -199,7 +225,7 @@ fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec } /// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with -/// `layers`, each top-level layer (but template layers) becomes an optional content group — +/// `layers`, each layer and sublayer (but template layers) becomes an optional content group — /// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print /// option is off, locked when it is locked — and the uses of its forms its optional content; /// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`, @@ -228,6 +254,11 @@ pub(crate) fn finish(pdf: Vec, doc: &Document, set: &PdfSettings, layers: bo } if layers { write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?; + for (n, _) in marks.iter().filter(|(_, m)| matches!(m, Mark::Layer(_))) { + if let Some(o) = objects.get(n) { + unbox(&pdf, o, &mut patch)?; + } + } } let pdf = patch.apply(&pdf, &xref)?; // The file still is what its standard says. @@ -235,8 +266,9 @@ pub(crate) fn finish(pdf: Vec, doc: &Document, set: &PdfSettings, layers: bo Ok(pdf) } -/// The optional content groups of `doc`'s top-level layers, the uses of their forms marked as -/// their content, and the catalog's `/OCProperties`. +/// The optional content groups of `doc`'s layers and sublayers ([`pdf_layers`]), the uses of their +/// forms marked as their content, and the catalog's `/OCProperties`, listing sublayers under their +/// layers. fn write_layers( pdf: &[u8], xref: &Xref, @@ -247,11 +279,16 @@ fn write_layers( set: &PdfSettings, ) -> Result<(), PdfError> { // One group per layer, in paint order (bottom first). + let layers = pdf_layers(doc); let mut groups: HashMap = HashMap::new(); let (mut all, mut off, mut locked) = (vec![], vec![], vec![]); - for (i, l) in doc.layers.iter().enumerate() { - let NodeKind::Layer { template: false, printable, .. } = l.kind else { continue }; - let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", i + 1)); + // Each layer's sublayers, and the top-level layers, in paint order. + let mut children: HashMap, Vec> = HashMap::new(); + for (i, PdfLayer { node: l, parent }) in layers.iter().enumerate() { + let siblings = children.entry(*parent).or_default(); + siblings.push(i); + let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", siblings.len())); + let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. }); let on = |b: bool| if b { "ON" } else { "OFF" }; let g = patch.add_object( format!("<>/Print<>>>>>", text_string(&name), on(l.visible), on(printable)) @@ -295,9 +332,7 @@ fn write_layers( add_resources(patch, res, "Properties", &entries)?; } let refs = |v: &[u32]| v.iter().map(|g| format!("{g} 0 R")).collect::>().join(" "); - // Layers panels list the top layer first. - let order: Vec = all.iter().rev().copied().collect(); - let mut config = format!("/Name(Layers)/Order[{}]", refs(&order)); + let mut config = format!("/Name(Layers)/Order[{}]", order(&children, &groups, None)); if !off.is_empty() { config.push_str(&format!("/OFF[{}]", refs(&off))); } @@ -313,3 +348,35 @@ fn write_layers( patch.replace(start + 2, start + 2, format!("/OCProperties<>>>", refs(&all)).into_bytes()); Ok(()) } + +/// A form's box as large as a page can be: the box of a layer's form is only the bounds of its +/// art, and a reader that makes a clipping group of a form's box (as importers do) would gather a +/// layer's art and its sublayers' in one group, which no layer can hold. A box round the whole +/// page clips nothing. +const UNBOXED: &[u8] = b"[-32767 -32767 32767 32767]"; + +/// Give the layer's form `o` the [`UNBOXED`] box. +fn unbox(pdf: &[u8], o: &Object<'_>, patch: &mut Patch) -> Result<(), PdfError> { + let Obj::Dict { start, end, .. } = o.dict else { return Err(bad("a layer's form has no dictionary")) }; + let dict = pdf.get(start..end).ok_or_else(|| bad("a layer's form can't be read"))?; + // The writer writes the box as an array of numbers. + let at = dict.windows(5).position(|w| w == b"/BBox").ok_or_else(|| bad("a layer's form has no box"))? + 5; + let open = at + dict.get(at..).and_then(|d| d.iter().position(|&c| c == b'[')).ok_or_else(|| bad("a layer's form box isn't an array"))?; + let close = open + dict.get(open..).and_then(|d| d.iter().position(|&c| c == b']')).ok_or_else(|| bad("a layer's form box isn't an array"))? + 1; + patch.replace(start + open, start + close, UNBOXED.to_vec()); + Ok(()) +} + +/// The `/Order` entries of `parent`'s sublayers (the top-level layers for none), the top one first +/// as Layers panels list them, each followed by an array of its own sublayers when it has some. +fn order(children: &HashMap, Vec>, groups: &HashMap, parent: Option) -> String { + let mut out = vec![]; + for &i in children.get(&parent).into_iter().flatten().rev() { + let Some(g) = groups.get(&i) else { continue }; + out.push(format!("{g} 0 R")); + if children.contains_key(&Some(i)) { + out.push(format!("[{}]", order(children, groups, Some(i)))); + } + } + out.join(" ") +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index f9f6db07..0bace225 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -537,14 +537,23 @@ struct Frame { children: Vec>, /// The first optional content group entered inside (where the frame's art goes). group: Option, + /// The group each child was drawn in, where the frame's art goes when the frame dissolves + /// into its parent (a layer's form drawing its sublayers' forms). + child_groups: Vec>, } impl Frame { fn new(kind: FrameKind) -> Self { - Self { kind, children: vec![], group: None } + Self { kind, children: vec![], group: None, child_groups: vec![] } } } +/// A dissolving frame's `children`, each with the group it was drawn in (`groups`, else the +/// frame's `group`). +fn grouped(children: Vec>, groups: Vec>, group: Option) -> impl Iterator, Option)> { + children.into_iter().zip(groups.into_iter().map(move |g| g.or(group)).chain(std::iter::repeat(group))) +} + /// Glyphs drawn consecutively with the same paint, merged into one path. struct GlyphRun { path: BezPath, @@ -846,8 +855,11 @@ impl<'p> Builder<'p> { // A frame's art goes where its first art was drawn (a clip can end in a later group). if root { self.root_groups.push(group.or(current)); - } else if f.group.is_none() { - f.group = group.or(current); + } else { + f.child_groups.push(group.or(current)); + if f.group.is_none() { + f.group = group.or(current); + } } } @@ -925,7 +937,7 @@ impl<'p> Builder<'p> { let node = match f.kind { FrameKind::Root => None, FrameKind::Skip => { - f.children.into_iter().for_each(|c| self.emit(c, group)); + grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); None } FrameKind::Clip(clip) => { @@ -934,7 +946,7 @@ impl<'p> Builder<'p> { None } else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) { // A rectangle around all of its art (a form's box) clips nothing. - f.children.into_iter().for_each(|c| self.emit(c, group)); + grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); None } else { let mut ch = vec![Arc::new(*clip)]; @@ -957,7 +969,7 @@ impl<'p> Builder<'p> { if children.is_empty() { None } else if plain { - children.into_iter().for_each(|c| self.emit(c, group)); + grouped(children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); None } else if let [only] = children.as_slice() && !only.is_container() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 2cbc4176..26174e8f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -10,7 +10,7 @@ //! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and //! given an output intent as the Output settings say. Hidden objects, //! guides and template layers are skipped, and so are non-printing layers unless asked for. -//! Top-level layers can be PDF layers (optional content groups with their visibility, print +//! Layers and sublayers can be PDF layers (optional content groups with their visibility, print //! state and lock), and overprinting fills and strokes overprint (Advanced). //! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the //! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index b01a3036..d81b7d5d 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -226,7 +226,7 @@ pub struct PdfSettings { pub fast_web_view: bool, /// The app opens the written file (frontends only; the writer ignores it). pub view_after_saving: bool, - /// Write top-level layers as PDF layers (optional content). + /// Write layers and sublayers as PDF layers (optional content). pub create_layers: bool, /// Keep the layers whose Print option is off (they are left out otherwise, unless /// `create_layers` is on). @@ -449,7 +449,7 @@ impl PdfSettings { within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") } - /// Top-level layers are written as PDF layers: asked for, at a version that has them. + /// Layers and sublayers are written as PDF layers: asked for, at a version that has them. pub fn writes_layers(&self) -> bool { self.create_layers && self.compatibility.has_layers() } diff --git a/crates/pdf/src/tests_layers.rs b/crates/pdf/src/tests_layers.rs index ba4d39f0..197090ab 100644 --- a/crates/pdf/src/tests_layers.rs +++ b/crates/pdf/src/tests_layers.rs @@ -131,6 +131,57 @@ fn pdf_layers_import_back_as_layers() { } } +/// The rows of `nodes` in paint order, depth first: `depth name visible` for layers, `depth fill` +/// for art. +fn tree(nodes: &[std::sync::Arc], depth: usize, out: &mut Vec) { + for n in nodes { + match &n.kind { + NodeKind::Layer { children, .. } => { + out.push(format!("{depth} {} {}", n.name.clone().unwrap_or_default(), n.visible)); + tree(children, depth + 1, out); + } + _ => out.push(format!("{depth} {:?}", n.appearance.fill().and_then(|f| f.paint.color()))), + } + } +} + +/// Sublayers are PDF layers listed under their layer's, hidden ones off with their art, and they +/// import back as sublayers (#372: other apps read a `.ai` file's PDF part). +#[test] +fn sublayers_are_nested_pdf_layers_and_import_back() { + let mut d = Document::new(200.0, 100.0); + d.layers.clear(); + let layer = |d: &mut Document, name: &str, visible: bool, children: Vec| { + let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0)); + l.visible = visible; + if let NodeKind::Layer { children: c, .. } = &mut l.kind { + c.extend(children.into_iter().map(std::sync::Arc::new)); + } + l + }; + let deep = vec![rect(&mut d, 90.0, GREEN)]; + let deep = layer(&mut d, "Deep", true, deep); + let sub = vec![rect(&mut d, 50.0, BLUE), deep]; + let sub = layer(&mut d, "Sub", false, sub); + let main = vec![rect(&mut d, 10.0, RED), sub]; + let main = layer(&mut d, "Main", true, main); + d.layers.push(main.into()); + let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}})); + oc_properties(&r.bytes, None, |props, xref| { + let props = props.expect("/OCProperties"); + assert_eq!(names(xref, props.get(b"OCGs")), ["Main", "Sub", "Deep"]); + assert_eq!(names(xref, props.get::>(b"D").unwrap().get(b"OFF")), ["Sub"]); + }); + let text = String::from_utf8_lossy(&r.bytes); + let order = text.split("/Order").nth(1).and_then(|o| o.split("]/").next()).unwrap().to_string(); + assert_eq!(order.matches('[').count(), 3, "each layer's sublayers in an array after it: {order}"); + let mut got = vec![]; + tree(&import(&r.bytes).unwrap().layers, 0, &mut got); + // Paint order, as it was. + let fill = |depth, c: Color| format!("{depth} {:?}", Some(c)); + assert_eq!(got, ["0 Main true".to_string(), fill(1, RED), "1 Sub false".into(), fill(2, BLUE), "2 Deep true".into(), fill(3, GREEN)]); +} + #[test] fn identical_layers_stay_apart() { let mut d = Document::new(200.0, 100.0); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 3c7bc79c..0cb7b627 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -455,7 +455,7 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { flag(ui, d, "viewAfterSaving", tl!("View PDF after saving"), true); // PDF layers need PDF 1.5, and PDF/X-1a and PDF/X-3 have none. let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); - flag(ui, d, "createLayers", tl!("Create PDF layers from top-level layers"), layers); + flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers); flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); if editor { heading(ui, tl!("Description")); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4ceed41..e0bf847b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -508,7 +508,7 @@ Create Object Mosaic… Crear mosaico de objetos… Create Outlines Crear contornos Create Path Crear trazado - Create PDF layers from top-level layers Crear capas PDF a partir de las capas de nivel superior + Create PDF layers from layers and sublayers Crear capas PDF a partir de las capas y subcapas Create PDF-Compatible File Crear archivo compatible con PDF Create Report Crear informe Create Slice Crear sector diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index decd91fd..0d0abf3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -508,7 +508,7 @@ Create Object Mosaic… Crea mosaico di oggetti… Create Outlines Crea contorni Create Path Crea tracciato - Create PDF layers from top-level layers Crea livelli PDF dai livelli principali + Create PDF layers from layers and sublayers Crea livelli PDF dai livelli e sottolivelli Create PDF-Compatible File Crea file compatibile PDF Create Report Crea rapporto Create Slice Crea sezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b479ea62..0576ef31 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -983,7 +983,7 @@ Create New Sublayer 新規サブレイヤーを作成 Create New Swatch… 新規スウォッチを作成… Create New {kind} Style 新しい{kind}スタイルを作成 - Create PDF layers from top-level layers 最上位レイヤーから PDF レイヤーを作成 + Create PDF layers from layers and sublayers レイヤーとサブレイヤーから PDF レイヤーを作成 Create PDF-Compatible File PDF 互換ファイルを作成 Create Path パスを作成 Create Slice スライスを作成 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9500ba89..5d74b821 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -497,7 +497,7 @@ Create New {kind} Style Criar Novo Estilo de {kind} Create Object Mosaic… Criar Mosaico de Objetos… Create Outlines Criar Contornos - Create PDF layers from top-level layers Criar camadas de PDF a partir das camadas de nível superior + Create PDF layers from layers and sublayers Criar camadas de PDF a partir das camadas e subcamadas Create PDF-Compatible File Criar Arquivo Compatível com PDF Create Path Criar Caminho Create Slice Criar Fatia diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 53cafffa..a40acd24 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -506,7 +506,7 @@ Create Object Mosaic… Создать мозаику объектов… Create Outlines Создать контуры Create Path Создать контур - Create PDF layers from top-level layers Создавать слои PDF из слоёв верхнего уровня + Create PDF layers from layers and sublayers Создавать слои PDF из слоёв и подслоёв Create PDF-Compatible File Создать файл, совместимый с PDF Create Report Создать отчёт Create Slice Создать фрагмент diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ddc8134f..1c6f1734 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -508,7 +508,7 @@ Create Object Mosaic… 创建对象马赛克… Create Outlines 创建轮廓 Create Path 创建路径 - Create PDF layers from top-level layers 从顶层图层创建 PDF 图层 + Create PDF layers from layers and sublayers 从图层和子图层创建 PDF 图层 Create PDF-Compatible File 创建与 PDF 兼容的文件 Create Report 创建报告 Create Slice 创建切片 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a9d704ba..55723ec8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -523,7 +523,7 @@ Create Object Mosaic… 建立物件馬賽克… Create Outlines 建立外框 Create Path 建立路徑 - Create PDF layers from top-level layers 從最上層圖層建立 PDF 圖層 + Create PDF layers from layers and sublayers 從圖層和子圖層建立 PDF 圖層 Create PDF-Compatible File 建立與 PDF 相容的檔案 Create Report 建立報告 Create Slice 建立切片 diff --git a/docs/mcp.md b/docs/mcp.md index 5e57c32a..6cfdeb6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -355,9 +355,11 @@ A `security` password encrypts the file (RC4 40-bit at PDF 1.3, RC4 128-bit at 1 Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the document's raster effects resolution, clipped the same way. -`createLayers` writes each top-level layer (template layers are left out) as a PDF layer, an optional content group -named as the layer: hidden layers are off, non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs -PDF 1.5 or later (at 1.4 it warns), and the file reopens with those layers. +`createLayers` writes each layer and sublayer (template layers are left out) as a PDF layer, an optional content group +named as the layer and listed under its parent layer's: hidden layers are off (their art is written, for the reader to +show), non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs PDF 1.5 or later (at 1.4 it +warns), and the file reopens with those layers and sublayers. A `.ai` file writes them unless `createLayers` is false, +so apps that read its PDF part find the layers, hidden ones included; hidden objects aren't in the PDF part. Images follow the `compression` settings of their kind (`color`, `gray`, or `mono` for black-and-white images): above `abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others From 674aef95b0ff9fd083bbd4ee0053283a15fee37c Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:26:28 -0700 Subject: [PATCH 33/74] M7: area type auto size and shrink text to fit Area Type Options gain a fit: none, autoHeight (Illustrator's Auto Size: the frame's height follows the text after every edit, resolved in refresh_bounds inside the edit so it shares the edit's undo step) and shrinkText {minPercent} (beyond Illustrator: overflowing text scales its sizes, leading and baseline shifts down at layout time by the largest factor, in 0.1 pt steps of the first run, that fits). The Auto Size New Area Type preference now applies. text.areaOptions and document.inspect report overflow and the shrink factor; the Area Type Options dialog offers fit and first baseline as dropdowns. --- ROADMAP.md | 8 +- crates/doc/src/lib.rs | 5 +- crates/doc/src/text.rs | 64 +++++++ crates/engine/src/cmd/create.rs | 11 +- crates/engine/src/cmd/textedit.rs | 5 +- crates/engine/src/cmd/threads.rs | 10 ++ crates/engine/src/cmd/typecmd.rs | 188 +++++++++++++++++--- crates/engine/src/inspect.rs | 14 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_areafit.rs | 242 ++++++++++++++++++++++++++ crates/engine/tests/command_sweep.rs | 48 +++++ crates/testkit/src/fixtures.rs | 6 + crates/text/src/layout.rs | 76 +++++++- crates/text/src/lib.rs | 29 ++- crates/text/src/tests_fit.rs | 163 +++++++++++++++++ crates/ui-egui/src/dialogs/command.rs | 4 + crates/ui-egui/src/menus.rs | 3 +- docs/mcp.md | 28 +++ 18 files changed, 871 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/tests_areafit.rs create mode 100644 crates/text/src/tests_fit.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5b1c0b75..1d46f6c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent tate-chu-yoko), Optical Margin Alignment, the hyphenation and justification options (the Every-line/Single-line composer choice is done), tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have - initial support. 35–48 h across type core and advanced. + initial support. 34–47 h across type core and advanced. 5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h. @@ -139,7 +139,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), 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. 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. 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:** 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), 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. 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). - **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -176,7 +176,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| Type core | 9 | 79% | 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, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | +| 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, 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b5650487..7f49ed11 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, - PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, + AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, + ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, + WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f1c6a733..62e9436d 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -664,6 +664,9 @@ pub struct AreaOptions { pub first_baseline_min: f64, /// Vertical alignment of the lines in each row/column. pub vertical_align: VerticalAlign, + /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub fit: AreaFit, } impl Default for AreaOptions { @@ -676,6 +679,67 @@ impl Default for AreaOptions { first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, vertical_align: VerticalAlign::Top, + fit: AreaFit::None, + } + } +} + +/// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or +/// `{"shrinkText": {"minPercent": 50}}`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AreaFit { + /// The frame keeps its size; text that doesn't fit overflows. + #[default] + None, + /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after + /// each edit, for rectangular frames of horizontal type in one row. + AutoHeight, + /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph + /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. + ShrinkText { + #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] + min_percent: f64, + }, +} + +impl AreaFit { + /// The smallest Shrink Text to Fit percentage allowed. + pub const MIN_PERCENT: f64 = 10.0; + /// The Shrink Text to Fit percentage new settings start with. + pub const DEFAULT_MIN_PERCENT: f64 = 50.0; + fn default_min_percent() -> f64 { + Self::DEFAULT_MIN_PERCENT + } + /// Its id: `none`, `autoHeight` or `shrinkText`. + pub fn id(self) -> &'static str { + match self { + AreaFit::None => "none", + AreaFit::AutoHeight => "autoHeight", + AreaFit::ShrinkText { .. } => "shrinkText", + } + } + /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` + /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). + pub fn parse(id: &str, min_percent: Option) -> Option { + match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "none" | "off" => Some(AreaFit::None), + "autoheight" | "autosize" => Some(AreaFit::AutoHeight), + "shrinktext" | "shrinktexttofit" | "shrink" => { + Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) + } + _ => None, + } + } + /// A Shrink Text minimum percentage within 10..100 (the default when not finite). + pub fn clamp_percent(p: f64) -> f64 { + if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } + } + /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. + pub fn min_scale(self) -> Option { + match self { + AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), + _ => None, } } } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dc73c63c..0f1d1978 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -415,10 +415,12 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; + t.area.fit = new_area_fit(s, a, "text.create")?; } if bool_or(p, "placeholder", false) { super::typemenu::fill_with_placeholder(&mut t); } else { + // Lays the text out (and sizes an Auto Size frame). super::typecmd::refresh_bounds(&mut t); } add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) @@ -471,6 +473,13 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut Ok(()) } +/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes +/// them), else Auto Size when the "Auto Size New Area Type" preference is on. +pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result { + let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None }; + Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default)) +} + /// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the /// defaults, filled with the current fill unless that is None or white (black then). pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 9ae8d384..a454bd25 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), @@ -472,6 +472,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { cached_baselines: Vec::new(), }; super::create::new_type_alignment(s, p, C, &mut t)?; + if !on_path { + t.area.fit = super::create::new_area_fit(s, p, C)?; + } if bool_or(p, "placeholder", false) { super::typemenu::fill_with_placeholder(&mut t); } else { diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 0b4d9615..03227f2b 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -93,6 +93,16 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { continue; } vectorcraft_text::edit::normalize(&mut story); + if live.len() >= 2 { + // Threaded frames keep their size: Auto Size is off for them (as in Illustrator). + for id in &live { + if text_of(doc, *id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight) + && let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) + { + t.area.fit = vectorcraft_doc::AreaFit::None; + } + } + } let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); let refs: Vec<&TextObject> = frames.iter().collect(); let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4c62a5c5..6b7daebf 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options", + "{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", has_selection, area_options ), @@ -97,13 +97,94 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) } -/// Recompute the layout caches (bounds and baselines) after a text edit. +/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing +/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type +/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo +/// step as the edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { + auto_height(t); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); } +/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one +/// subpath, as large as its bounds) of horizontal type in one row, with some height. +fn auto_height_frame(t: &TextObject) -> Option { + use vectorcraft_geom::Shape; + let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None }; + if t.area.fit != vectorcraft_doc::AreaFit::AutoHeight || t.vertical || t.area.rows > 1 || frame.subpaths.len() != 1 { + return None; + } + let b = frame.bounds()?; + let area = frame.to_bezpath().area().abs(); + let finite = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()); + (finite && b.height() > 1e-6 && b.width() > 1e-6 && (area - b.area()).abs() <= b.area() * 1e-6).then_some(b) +} + +/// Auto Size: move the bottom of a rectangular area type frame to just below its last line (plus +/// the inset), so the frame's height follows the text. Text in several columns gets the least +/// height (to 0.01 pt) at which it fits. True when the frame changed. +fn auto_height(t: &mut TextObject) -> bool { + use vectorcraft_geom::Shape; + let Some(b) = auto_height_frame(t) else { return false }; + let db = vectorcraft_text::FontDb::global(); + // Lay out in a frame as tall as the canvas allows: everything the width lets through flows. + let tall = (crate::MAX_COORD - b.y0).min(1.0e5); + if tall <= 1.0 { + return false; + } + let mut probe = t.clone(); + let with_size = |probe: &mut TextObject, w: f64, h: f64| { + if let vectorcraft_doc::TextKind::Area { frame } = &mut probe.kind { + *frame = PathData::from_bezpath(&vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + w, b.y0 + h).to_path(0.1)); + } + }; + // Columns: all the text in one column as wide as each of them is surely tall enough. + let cols = t.area.columns.max(1); + let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() }; + with_size(&mut probe, col_w, tall); + probe.area.columns = 1; + let lay = vectorcraft_text::layout(db, &probe); + let inset = t.area.inset.max(0.0); + let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false }; + // The layout's fit test is `baseline + descent <= bottom + 0.01`: the last line fits exactly. + let mut h = (bottom + inset - b.y0).clamp(1.0, tall); + if cols > 1 { + // The least height (to 0.01 pt) at which the columns hold the text. + probe.area.columns = cols; + let fits = |probe: &mut TextObject, h: f64| { + with_size(probe, b.width(), h); + !vectorcraft_text::layout(db, probe).overflow + }; + if !fits(&mut probe, h) { + return false; + } + let (mut lo, mut hi) = (1.0, h); + for _ in 0..24 { + if hi - lo <= 0.01 { + break; + } + let mid = (lo + hi) * 0.5; + if fits(&mut probe, mid) { hi = mid } else { lo = mid } + } + h = hi; + } + if (h - b.height()).abs() <= 1e-6 { + return false; + } + // Scale the frame about its top edge, keeping its anchors (and their order) as they are. + let a = Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, h / b.height()) * Affine::translate((0.0, -b.y0)); + let vectorcraft_doc::TextKind::Area { frame } = &mut t.kind else { return false }; + let mut next = frame.clone(); + next.transform(a); + if next.bounds().is_none_or(|nb| ![nb.x0, nb.y0, nb.x1, nb.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD)) { + return false; + } + *frame = next; + true +} + /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![]; @@ -292,10 +373,18 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje } _ => false, }; + let height = |t: &TextObject| auto_height_frame(t).map(|b| b.height()); + let was = height(t); if !(f(t) && within(t)) { t.kind = before; return false; } + // Setting the height by hand turns Auto Size off (as in Illustrator); a new width keeps it. + if let (Some(a), Some(b)) = (was, height(t)) + && (a - b).abs() > 1e-6 + { + t.area.fit = vectorcraft_doc::AreaFit::None; + } refresh_bounds(t); true } @@ -334,6 +423,60 @@ fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { reshape_area_with(t, |t| t.transform_area(a)) } +/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`, +/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent` +/// while the fit isn't Shrink Text). +pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result> { + let min = p.get("fitMinPercent").and_then(Value::as_f64); + let cur_min = match cur { + AreaFit::ShrinkText { min_percent } => Some(min_percent), + _ => None, + }; + let fit = match p.get("fit") { + None | Some(Value::Null) => match (cur, min) { + (AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m }, + _ => return Ok(None), + }, + Some(Value::String(id)) => { + AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))? + } + Some(v) => match serde_json::from_value::(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? { + AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) }, + f => f, + }, + }; + Ok(Some(match fit { + AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) }, + f => f, + })) +} + +/// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as +/// its id and `fitMinPercent` beside it, the frame's `width` and `height`, and whether the text +/// `overflow`s its frame at `fitScale` (Shrink Text to Fit's factor; 1 unshrunk). +pub(crate) fn area_options_json(t: &TextObject) -> Result { + let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + if let Some(o) = v.as_object_mut() { + o.insert("fit".into(), json!(t.area.fit.id())); + let min = match t.area.fit { + AreaFit::ShrinkText { min_percent } => min_percent, + _ => AreaFit::DEFAULT_MIN_PERCENT, + }; + o.insert("fitMinPercent".into(), json!(min)); + if let Some((w, h, _)) = area_size(t) { + o.insert("width".into(), json!(w)); + o.insert("height".into(), json!(h)); + } + o.insert("overflow".into(), json!(lay.overflow)); + o.insert("fitScale".into(), json!(lay.fit_scale)); + } + Ok(v) +} + +/// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are. +const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign"]; + fn area_options(s: &mut Session, p: &Value) -> Result { const C: &str = "text.areaOptions"; let ids: Vec = { @@ -343,34 +486,35 @@ fn area_options(s: &mut Session, p: &Value) -> Result { let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?; let options = |s: &Session| -> Result { let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?; - let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; - if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) { - o.insert("width".into(), json!(w)); - o.insert("height".into(), json!(h)); - } - Ok(v) + area_options_json(t) }; - let mut v = options(s)?; - let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0)); - let (w, h) = (size("width"), size("height")); - let mut changed = w.is_some() || h.is_some(); + let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default(); + let fit = fit_param(p, cur.fit, C)?; + let mut v = serde_json::to_value(&cur).map_err(|e| EngineError::Other(e.to_string()))?; + let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0)); + let (w, mut h) = (size("width"), size("height")); + let mut changed = w.is_some() || h.is_some() || fit.is_some(); if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) { - for (k, val) in src { - if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") { - o.insert(k.clone(), val.clone()); - changed = true; - } + for (k, val) in src.iter().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())) { + o.insert(k.clone(), val.clone()); + changed = true; } } if !changed { - return Ok(v); + return options(s); } let mut opts: vectorcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?; opts.rows = opts.rows.clamp(1, 100); opts.columns = opts.columns.clamp(1, 100); - opts.gutter = opts.gutter.clamp(0.0, 10_000.0); - opts.inset = opts.inset.clamp(0.0, 10_000.0); - opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0); + let finite = |x: f64| if x.is_finite() { x } else { 0.0 }; + opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0); + opts.inset = finite(opts.inset).clamp(0.0, 10_000.0); + opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0); + opts.fit = fit.unwrap_or(cur.fit); + if opts.fit == AreaFit::AutoHeight { + // Auto Size sets the height (Illustrator's dialog turns the Height field off). + h = None; + } s.edit("Area Type Options", |d, _| { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 89f9fe12..9881c347 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -80,7 +80,19 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value { v["anchors"] = json!(path.anchor_count()); v["closed"] = json!(path.is_closed()); } - NodeKind::Text(t) => v["text"] = json!(t.plain_text()), + NodeKind::Text(t) => { + v["text"] = json!(t.plain_text()); + // Area type and type on a path: does the text fit (the red overflow "+")? Area type + // also reports its fit and Shrink Text's factor (1 unshrunk). + if !matches!(t.kind, vectorcraft_doc::TextKind::Point) { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + v["overflow"] = json!(lay.overflow); + if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) { + v["fit"] = json!(t.area.fit.id()); + v["fitScale"] = json!(lay.fit_scale); + } + } + } _ => {} } if let Some(ch) = n.children() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 874cb74d..4b8668d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1293,6 +1293,8 @@ mod tests_adjust; #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_areafit; +#[cfg(test)] mod tests_assets; #[cfg(test)] mod tests_attributes; diff --git a/crates/engine/src/tests_areafit.rs b/crates/engine/src/tests_areafit.rs new file mode 100644 index 00000000..5467c8cb --- /dev/null +++ b/crates/engine/src/tests_areafit.rs @@ -0,0 +1,242 @@ +//! Area type fitting: Auto Size (the frame's height follows the text), Shrink Text to Fit, and +//! overflow reporting. + +use serde_json::{Value, json}; +use vectorcraft_doc::{AreaFit, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::Rect; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId { + let mut a = json!({"width": w, "height": h}); + if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) { + a.extend(e.clone()); + } + let id = s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap(); + NodeId(id) +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn frame(s: &Session, id: NodeId) -> Rect { + match text(s, id).kind { + TextKind::Area { frame } => frame.bounds().unwrap(), + k => panic!("not area type: {k:?}"), + } +} + +fn overflows(s: &Session, id: NodeId) -> bool { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).overflow +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn area_options_set_and_report_the_fit() { + let mut s = session(); + let id = area(&mut s, 120.0, 30.0, STORY, json!({})); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["fit"].as_str(), q["fitMinPercent"].as_f64()), (Some("none"), Some(50.0))); + assert_eq!((q["overflow"].as_bool(), q["fitScale"].as_f64()), (Some(true), Some(1.0))); + let n = undo_len(&s); + let r = s.execute("text.areaOptions", &json!({"fit": "shrinkText", "fitMinPercent": 20})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + assert_eq!((r["fit"].as_str(), r["fitMinPercent"].as_f64()), (Some("shrinkText"), Some(20.0))); + assert_eq!(r["overflow"], false, "{r}"); + let f = r["fitScale"].as_f64().unwrap(); + assert!((0.2..1.0).contains(&f), "{f}"); + assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 20.0 }); + assert_eq!(text(&s, id).runs[0].style.size, 12.0, "the stored size stays"); + // The minimum alone, clamped; the object form; and bad values. + s.execute("text.areaOptions", &json!({"fitMinPercent": 3})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 10.0 }); + s.execute("text.areaOptions", &json!({"fit": {"shrinkText": {"minPercent": 95}}})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::ShrinkText { min_percent: 95.0 }); + assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["overflow"], true, "95 % is not enough"); + assert!(s.execute("text.areaOptions", &json!({"fit": "squash"})).is_err()); + assert!(s.execute("text.areaOptions", &json!({"fit": 7})).is_err()); + // The reply's own keys go back in unchanged (the dialog sends everything). + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + let n = undo_len(&s); + let again = s.execute("text.areaOptions", &q).unwrap(); + assert_eq!(again, q); + assert_eq!(undo_len(&s), n + 1); + // Undo back to no fit. + while text(&s, id).area.fit != AreaFit::None { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(overflows(&s, id)); +} + +#[test] +fn inspect_reports_overflow_and_fit_scale() { + let mut s = session(); + let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 10})); + s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap(); + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let kids = doc["layers"][0]["children"].as_array().unwrap(); + let node = kids.iter().find(|k| k["id"] == id.0).unwrap(); + assert_eq!(node["overflow"], false); + assert_eq!(node["fit"], "shrinkText"); + assert!(node["fitScale"].as_f64().unwrap() < 1.0); + let point = kids.iter().find(|k| k["id"] != id.0).unwrap(); + assert!(point.get("overflow").is_none() && point.get("fitScale").is_none(), "point type never overflows"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.areaOptions", &json!({"fit": "none"})).unwrap(); + let doc = s.execute("document.inspect", &json!({})).unwrap(); + let node = doc["layers"][0]["children"].as_array().unwrap().iter().find(|k| k["id"] == id.0).unwrap().clone(); + assert_eq!((node["overflow"].as_bool(), node["fitScale"].as_f64(), node["fit"].as_str()), (Some(true), Some(1.0), Some("none"))); +} + +#[test] +fn auto_height_follows_edits_in_the_same_undo_step() { + let mut s = session(); + let id = area(&mut s, 200.0, 300.0, "One line", json!({"fit": "autoHeight"})); + let one = frame(&s, id); + assert!(one.height() < 30.0, "the frame fits one line: {one:?}"); + assert_eq!((one.x0, one.y0, one.width()), (0.0, 0.0, 200.0), "top and width stay"); + assert!(!overflows(&s, id)); + let n = undo_len(&s); + s.execute("text.editRange", &json!({"id": id.0, "start": 8, "insert": format!(" {STORY}")})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let grown = frame(&s, id); + assert!(grown.height() > one.height() * 2.0, "{grown:?}"); + assert!(!overflows(&s, id), "the grown frame holds the text"); + // Tight: a point less overflows. + let mut t = text(&s, id); + t.area.fit = AreaFit::None; + t.kind = TextKind::Area { + frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, grown.height() - 1.0), 0.1)), + }; + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Character changes resize it too. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 40})).unwrap(); + assert!(frame(&s, id).height() > grown.height()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), grown); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), one, "one undo restores the text and the frame"); + assert_eq!(text(&s, id).plain_text(), "One line"); +} + +#[test] +fn auto_height_follows_typing_with_the_type_tool() { + let mut s = session(); + s.prefs.auto_size_area_type = true; + // An empty frame (no placeholder text), so its first height is one line. + s.prefs.placeholder_text = false; + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for (kind, (x, y)) in [(PointerKind::Down, (100.0, 100.0)), (PointerKind::Drag, (300.0, 400.0)), (PointerKind::Up, (300.0, 400.0))] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight, "the preference gives new area type Auto Size"); + let empty = frame(&s, id); + assert!(empty.height() < 30.0, "{empty:?}"); + let n = undo_len(&s); + for word in STORY.split_inclusive(' ') { + s.tool_text(word, v).unwrap(); + } + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), STORY); + assert_eq!(undo_len(&s), n + 1, "one typing step"); + let typed = frame(&s, id); + assert!(typed.height() > empty.height() * 2.0, "{typed:?}"); + assert!(!overflows(&s, id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), empty); + assert_eq!(text(&s, id).plain_text(), ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), typed); +} + +#[test] +fn auto_height_in_columns_and_by_hand() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, &STORY.repeat(3), json!({"fit": "autoHeight"})); + let one = frame(&s, id).height(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + // Height is ignored while Auto Size is on. + let r = s.execute("text.areaOptions", &json!({"columns": 2, "gutter": 12, "height": 500})).unwrap(); + let two = r["height"].as_f64().unwrap(); + assert!(two < 500.0 && two > one * 0.5, "{one} → {two}"); + assert_eq!(r["overflow"], false); + assert_eq!(r["fit"], "autoHeight"); + // The least height the two columns fit: a point less overflows, and both columns are used. + let mut t = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.glyphs.iter().any(|g| g.origin.x > 160.0), "text flows into the second column"); + t.area.fit = AreaFit::None; + t.kind = TextKind::Area { + frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 300.0, two - 1.0), 0.1)), + }; + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Dragging the bottom edge sets the height by hand: Auto Size goes off. + let b = frame(&s, id); + s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]], "dx": 0, "dy": 50})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::None); + assert!((frame(&s, id).height() - (b.height() + 50.0)).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + // A new width keeps it: the height follows the reflow. + s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 1], [0, 2]], "dx": 100, "dy": 0})).unwrap(); + assert_eq!(text(&s, id).area.fit, AreaFit::AutoHeight); + assert!(frame(&s, id).height() < b.height()); + // Point type has no frame to size. + let p = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [p]})).unwrap(); + assert!(s.execute("text.areaOptions", &json!({"fit": "autoHeight"})).is_err(), "point type has no area options"); +} + +#[test] +fn shrink_text_round_trips_through_the_native_format() { + let mut s = session(); + let id = area(&mut s, 120.0, 30.0, STORY, json!({"fit": "shrinkText", "fitMinPercent": 35})); + let doc = s.doc().unwrap().doc.clone(); + let bytes = vectorcraft_format::save(&doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.area.fit, AreaFit::ShrinkText { min_percent: 35.0 }); +} + +#[test] +fn auto_height_follows_the_leading_model_and_character_alignment() { + let mut s = session(); + let id = area(&mut s, 200.0, 300.0, STORY, json!({"fit": "autoHeight"})); + let roman = frame(&s, id); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + // Top-to-Top leading: the first line moves up to touch the frame, the frame follows. + s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); + let em = frame(&s, id); + assert_ne!(em.height(), roman.height()); + assert!(!overflows(&s, id), "the frame holds the text under emBoxTop"); + // Mixed sizes aligned on the em box centre still fit exactly. + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 3, "size": 30})).unwrap(); + s.execute("text.setFormat", &json!({"charAlign": "emBoxCenter"})).unwrap(); + let mixed = frame(&s, id); + assert!(mixed.height() > em.height()); + assert!(!overflows(&s, id)); + let mut t = text(&s, id); + t.area.fit = AreaFit::None; + t.kind = TextKind::Area { + frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(0.0, 0.0, 200.0, mixed.height() - 1.0), 0.1)), + }; + assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "tight"); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index c85cda5b..bdb85e8c 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -311,6 +311,54 @@ fn structured_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Area Type Options with junk fit values, on selected area type (the fixtures have none), with +/// text that overflows: never a panic, the document stays sound, and editing still works. +#[test] +fn area_options_fit_junk() { + let cases = [ + json!({"fit": "shrinkText", "fitMinPercent": 1e308}), + json!({"fit": "shrinkText", "fitMinPercent": -1e308}), + json!({"fit": "shrinkText", "fitMinPercent": "x"}), + json!({"fit": {"shrinkText": {"minPercent": -5}}}), + json!({"fit": {"shrinkText": {"minPercent": "a"}}}), + json!({"fit": {"shrinkText": null}}), + json!({"fit": {"autoHeight": 1}}), + json!({"fit": {"bogus": {}}}), + json!({"fit": [1, 2]}), + json!({"fit": ""}), + json!({"fit": "AUTO-HEIGHT", "height": 1e308, "width": -1e308}), + json!({"fit": "autoHeight", "columns": u64::MAX, "gutter": 1e308, "inset": 1e308}), + json!({"fit": "autoHeight", "rows": 5, "inset": -1}), + json!({"fit": "shrinkText", "columns": 100, "gutter": 0, "inset": 10000}), + json!({"fitMinPercent": 50}), + json!({"fit": "none", "fitMinPercent": 1e-308}), + ]; + let story = "Words that overflow a small frame. ".repeat(40); + let mut failures = vec![]; + for (size, (w, h)) in [(12.0, (120.0, 40.0)), (0.1, (1.0, 1.0)), (1296.0, (100_000.0, 1.0))] { + for p in &cases { + let mut s = Fixture::Multi.session(); + let made = s.execute("text.create", &json!({"x": 10, "y": 10, "size": size, "text": story, "area": {"width": w, "height": h}})); + let Ok(made) = made else { continue }; + let id = made["id"].as_u64().unwrap_or(0); + let r = catch_quiet(|| s.execute("text.areaOptions", p)); + match r { + Err(m) => failures.push(format!("PANIC text.areaOptions {p} [{size} pt, {w}×{h}]: {m}")), + Ok(_) => { + let typed = catch_quiet(|| s.execute("text.editRange", &json!({"id": id, "start": 0, "insert": "More words. "}))); + if typed.is_err() { + failures.push(format!("PANIC typing after text.areaOptions {p}")); + } + if let Err(e) = check_session(&s) { + failures.push(format!("text.areaOptions {p} [{size} pt]: {e}")); + } + } + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + /// After a successful fuzzed call, the document still round-trips and exports. #[test] fn fuzzed_calls_keep_documents_serializable() { diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index c6d83913..d02b944a 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -123,6 +123,12 @@ pub fn rich_session() -> Session { )); select(&mut s, &[area]); exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2})); + let _ = id_of(&exec( + &mut s, + "text.create", + json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.", + "area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}), + )); exec(&mut s, "layer.new", json!({"name": "Top"})); let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0); let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d3d1ec40..82f2f68b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -128,14 +128,88 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { first_baseline: a.first_baseline, first_baseline_min: a.first_baseline_min, vertical_align: a.vertical_align, + fit: a.fit, ..LayoutOptions::default() }; layout_with(db, t, &opts) } +/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). +const SHRINK_PASSES: usize = 11; + /// Lay out a text object with explicit options (area type rows/columns, inset, first baseline, -/// composer, OpenType features). +/// fit, composer, OpenType features). pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { + let first = layout_once(db, t, opts); + match (opts.fit.min_scale(), &t.kind) { + (Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first), + _ => first, + } +} + +/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which +/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size +/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there, +/// still overflowing. None (keep the full-size layout) when the text has no usable size. +fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option { + let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?; + if !(min.is_finite() && min < 1.0) { + return None; + } + let tenths = size * 10.0; + // Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow + // unless it is below the full size; `lo` is the smallest allowed. + let mut lo = (tenths * min).ceil().max(1.0); + let mut hi = tenths.floor(); + if hi < lo { + hi = lo; + } + let at = |k: f64| -> TextLayout { + let f = (k / tenths).clamp(min, 1.0); + let mut l = layout_once(db, &scaled(t, f), opts); + l.fit_scale = f; + l + }; + let mut passes = 0; + let mut best: Option = None; + // `hi` itself may fit when the size isn't a whole number of tenths. + if hi < tenths && hi > lo { + let l = at(hi); + passes += 1; + if !l.overflow { + return Some(l); + } + } + // Invariant: everything at `hi` or above overflows; `lo` fits or is the floor. + while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES { + let mid = ((lo + hi) * 0.5).floor(); + let l = at(mid); + passes += 1; + if l.overflow { + hi = mid; + } else { + lo = mid; + best = Some(l); + } + } + // `best` is the layout at `lo` once `lo` has moved; at the floor it still needs laying out. + Some(best.unwrap_or_else(|| at(lo))) +} + +/// `t` with every run's size, explicit leading and baseline shift scaled by `f` (auto leading +/// follows the size; paragraph spacing stays). +fn scaled(t: &TextObject, f: f64) -> TextObject { + let mut s = t.clone(); + for r in &mut s.runs { + r.style.size *= f; + r.style.leading = r.style.leading.map(|l| l * f); + r.style.baseline_shift *= f; + } + s +} + +/// One layout pass, without fitting. +fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); let mut off = 0; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index a019232a..69919b17 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -31,7 +31,7 @@ use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; -pub use vectorcraft_doc::{FirstBaseline, VerticalAlign}; +pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign}; /// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`]. pub use vectorcraft_doc::Composer; @@ -52,6 +52,9 @@ pub struct LayoutOptions { pub first_baseline_min: f64, /// Vertical alignment of the lines in each row/column (Area Type Options "Align"). pub vertical_align: VerticalAlign, + /// Area type only: Shrink Text to Fit scales overflowing text down at layout time (see + /// [`TextLayout::fit_scale`]); the other fits are the engine's business and lay out as `None`. + pub fit: AreaFit, /// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`). pub composer: Option, pub features: OtFeatures, @@ -67,6 +70,7 @@ impl Default for LayoutOptions { first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, vertical_align: VerticalAlign::Top, + fit: AreaFit::None, composer: None, features: OtFeatures::default(), } @@ -131,7 +135,7 @@ pub struct LineInfo { pub region: usize, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct TextLayout { /// Lines retain inline/block coordinates; glyph geometry is in physical text space. pub vertical: bool, @@ -149,6 +153,25 @@ pub struct TextLayout { pub on_path: bool, /// Area type: the frame cells text flowed into (one per row/column). pub frames: Vec, + /// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled + /// by to fit its frame (1.0 when the text is not shrunk). + pub fit_scale: f64, +} + +impl Default for TextLayout { + fn default() -> Self { + Self { + vertical: false, + line_xf: Affine::IDENTITY, + glyphs: Vec::new(), + lines: Vec::new(), + bounds: Rect::ZERO, + overflow: false, + on_path: false, + frames: Vec::new(), + fit_scale: 1.0, + } + } } impl TextLayout { @@ -450,6 +473,8 @@ mod tests_bidi; #[cfg(test)] mod tests_embed; #[cfg(test)] +mod tests_fit; +#[cfg(test)] mod tests_scripts; #[cfg(test)] mod tests_snap; diff --git a/crates/text/src/tests_fit.rs b/crates/text/src/tests_fit.rs new file mode 100644 index 00000000..87fdb51d --- /dev/null +++ b/crates/text/src/tests_fit.rs @@ -0,0 +1,163 @@ +//! Area type fitting: Shrink Text to Fit. + +use super::*; +use kurbo::Shape; +use vectorcraft_doc::{AreaFit, CharStyle, TextKind}; +use vectorcraft_geom::PathData; + +const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \ +The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing, and adjusting the space \ +between pairs of letters."; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn area(text: &str, size: f64, frame: Rect, fit: AreaFit) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() }); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.area.fit = fit; + t +} + +fn shrink(min_percent: f64) -> AreaFit { + AreaFit::ShrinkText { min_percent } +} + +/// `t` laid out without fitting at `size` (every run). +fn at_size(t: &TextObject, size: f64) -> TextLayout { + let mut p = t.clone(); + p.area.fit = AreaFit::None; + for r in &mut p.runs { + r.style.size = size; + } + layout(db(), &p) +} + +#[test] +fn shrink_text_makes_overflowing_text_fit() { + let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(30.0)); + assert!(at_size(&t, 14.0).overflow, "the text overflows at full size"); + let l = layout(db(), &t); + assert!(!l.overflow, "shrunk to fit"); + assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale); + assert!(l.glyphs.len() > 100); + // The first run's scaled size is a whole number of tenths of a point. + let size = 14.0 * l.fit_scale; + assert!((size * 10.0 - (size * 10.0).round()).abs() < 1e-6, "{size}"); + // Maximal: a tenth of a point larger overflows; and the layout is the one at that size. + assert!(at_size(&t, size + 0.1).overflow, "{size} + 0.1 pt still fits"); + let same = at_size(&t, size); + assert_eq!((same.lines.len(), same.glyphs.len(), same.overflow), (l.lines.len(), l.glyphs.len(), false)); + // Deterministic. + assert_eq!(layout(db(), &t).fit_scale, l.fit_scale); +} + +#[test] +fn shrink_text_respects_its_minimum() { + let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0)); + let l = layout(db(), &t); + assert!(l.overflow, "still overflows at 90 %"); + assert!((l.fit_scale - 0.9).abs() < 1e-9, "{}", l.fit_scale); + // Out-of-range minimums are clamped (10 %). + let l = layout(db(), &area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(0.0))); + assert!(l.fit_scale >= 0.1 - 1e-9, "{}", l.fit_scale); +} + +#[test] +fn shrink_text_is_a_no_op_when_the_text_fits() { + let t = area("Short text", 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(50.0)); + let l = layout(db(), &t); + let plain = at_size(&t, 14.0); + assert!(!l.overflow); + assert_eq!(l.fit_scale, 1.0); + let o = |l: &TextLayout| l.glyphs.iter().map(|g| g.origin).collect::>(); + assert_eq!(o(&l), o(&plain)); +} + +#[test] +fn shrink_text_fills_columns() { + let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 300.0, 70.0), shrink(20.0)); + t.area.columns = 2; + t.area.gutter = 12.0; + let l = layout(db(), &t); + assert!(!l.overflow); + assert!(l.fit_scale < 1.0); + assert_eq!(l.frames.len(), 2); + let second = l.frames[1]; + assert!(l.glyphs.iter().any(|g| g.origin.x >= second.x0), "text flows into the second column"); + let size = 14.0 * l.fit_scale; + assert!(at_size(&t, size + 0.1).overflow, "maximal across columns too"); +} + +#[test] +fn shrink_text_scales_leading_and_baseline_shift_not_paragraph_spacing() { + let mut t = area(©.repeat(2), 12.0, Rect::new(0.0, 0.0, 200.0, 200.0), shrink(10.0)); + for r in &mut t.runs { + r.style.leading = Some(20.0); + } + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0); + let gap = l.lines[2].baseline - l.lines[1].baseline; + assert!((gap - 20.0 * l.fit_scale).abs() < 1e-6, "{gap} vs {}", 20.0 * l.fit_scale); +} + +#[test] +fn point_type_and_type_on_a_path_ignore_fit() { + let mut t = TextObject::point(Point::ZERO, COPY, CharStyle::default()); + t.area.fit = shrink(10.0); + assert_eq!(layout(db(), &t).fit_scale, 1.0); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (50.0, 0.0)).to_path(0.1)), start: 0.0, end: None }; + let l = layout(db(), &t); + assert!(l.overflow, "text runs past the path end"); + assert_eq!(l.fit_scale, 1.0); +} + +#[test] +fn shrink_text_guards_degenerate_sizes() { + for size in [0.0, 0.01, f64::NAN, 1296.0] { + let l = layout(db(), &area(COPY, size, Rect::new(0.0, 0.0, 50.0, 20.0), shrink(10.0))); + assert!(l.fit_scale.is_finite() && l.fit_scale > 0.0 && l.fit_scale <= 1.0, "{size}: {}", l.fit_scale); + } + // A zero-height frame. + let l = layout(db(), &area(COPY, 12.0, Rect::new(0.0, 0.0, 50.0, 0.0), shrink(10.0))); + assert!(l.overflow); +} + +#[test] +fn shrink_text_follows_top_to_top_leading_and_character_alignment() { + use vectorcraft_doc::{CharAlign, LeadingModel, TextRun}; + // Mixed sizes aligned on the em box centre, leading measured from em box top to top. + let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(20.0)); + t.para.leading_model = LeadingModel::EmBoxTop; + let big = CharStyle { size: 28.0, leading: Some(32.0), char_align: CharAlign::EmBoxCenter, ..CharStyle::default() }; + t.runs.insert(0, TextRun { text: "Big ".into(), style: big }); + for r in &mut t.runs { + r.style.char_align = CharAlign::EmBoxCenter; + } + let l = layout(db(), &t); + assert!(!l.overflow, "shrunk to fit under the top-to-top model"); + assert!(l.fit_scale < 1.0); + // The shrunk layout is the same text laid out at the scaled sizes and leading. + let mut manual = t.clone(); + manual.area.fit = AreaFit::None; + for r in &mut manual.runs { + r.style.size *= l.fit_scale; + r.style.leading = r.style.leading.map(|v| v * l.fit_scale); + } + let m = layout(db(), &manual); + let o = |l: &TextLayout| l.glyphs.iter().map(|g| (g.origin.x, g.origin.y)).collect::>(); + assert_eq!(o(&l), o(&m)); + // First line still touches the frame top (top-to-top: em box top at y = 0, scaled). + let first = l.lines.first().map(|li| li.baseline).unwrap_or(0.0); + assert!(first > 0.0 && first <= 28.0 * l.fit_scale + 1e-6, "{first}"); + // Maximal: the next 0.1 pt step of the first run overflows. + let step = 0.1 / 28.0; + let mut bigger = manual.clone(); + for (r, orig) in bigger.runs.iter_mut().zip(&t.runs) { + r.style.size = orig.style.size * (l.fit_scale + step); + r.style.leading = orig.style.leading.map(|v| v * (l.fit_scale + step)); + } + assert!(layout(db(), &bigger).overflow); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 52d6e211..f58066d8 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -38,6 +38,7 @@ fn choices(command: &str, key: &str) -> Option { match (command, key) { ("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS), ("type.pathOptions", "alignToPath") => Some(PATH_ALIGN), + ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), _ => None, @@ -47,6 +48,9 @@ fn choices(command: &str, key: &str) -> Option { /// Type on a Path Options › Align to Path. const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")]; +/// Area Type Options › Fit. +const AREA_FIT: form::Choices = &[("None", "none"), ("Auto Size", "autoHeight"), ("Shrink Text to Fit", "shrinkText")]; + /// Area Type Options › First Baseline. const FIRST_BASELINE: form::Choices = &[("Ascent", "ascent"), ("Cap Height", "capHeight"), ("x Height", "xHeight"), ("Leading", "leading"), ("Fixed", "fixed")]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3ee6083d..2a73a365 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2848,7 +2848,8 @@ pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { /// are. fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { Some(match id { - "text.areaOptions" => ("Area Type Options", &[]), + // Whether the text overflows, and Shrink Text's factor, are read-only facts. + "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), _ => return None, }) diff --git a/docs/mcp.md b/docs/mcp.md index b3ab77bf..e25951a1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2217,6 +2217,34 @@ again, so SVG, PDF and EPS show the aligned text. {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"verticalAlign":"center"}}} ``` +## Fitting area type: Auto Size, Shrink Text to Fit, overflow + +`text.areaOptions` (and `text.create`'s `area`, `text.createInPath` in area mode) take a `fit`: + +- `none` (default): the frame keeps its size; text that doesn't fit overflows (the red "+"). +- `autoHeight`: Auto Size. After every edit (typing, `text.editRange`, character and paragraph changes, a new + width) the frame's bottom moves to just below the last line plus the inset, in the same undo step as the edit. + In several columns the frame gets the least height (to 0.01 pt) at which the columns hold the text. It applies + to rectangular frames of horizontal type in one row; `height` is ignored while it is on, and setting the height + by hand (a handle drag, `text.reshapeArea` moving the bottom) turns it off, as does threading the frame. The + `autoSizeAreaType` preference ("Auto Size New Area Type") gives new area type `autoHeight`. +- `shrinkText` (beyond the reference app), with `fitMinPercent` (10–100, default 50): when the text overflows, + every run's size, leading (explicit leading; auto leading follows the size) and baseline shift are scaled by the + largest factor down to `fitMinPercent` % that makes it fit. Paragraph spacing and indents stay. The stored + sizes don't change: the scaling happens at layout time, so rendering and every export see it. The factor is + found by bisection over the first run's size in steps of 0.1 pt (its scaled size is a whole number of tenths of + a point, so the result is deterministic); at `fitMinPercent` the text may still overflow. + +The file stores `"fit": "autoHeight"` or `"fit": {"shrinkText": {"minPercent": 40}}` in the object's `area`; the +command also accepts that object form. The query (and every reply) of `text.areaOptions` reports `fit` as its id, +`fitMinPercent`, `overflow` (the text doesn't fit its frame) and `fitScale` (Shrink Text's factor, 1 unshrunk). +`document.inspect` (and `document.node {summary: true}`) reports `overflow` for area type and type on a path (text past +the end of the path), and `fit` and `fitScale` for area type. + +```json +{"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} +``` + ## Converting between point type and area type With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside From cfe8eaafaf222f8b8c55622870404d578e98c594 Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 21:34:38 +0100 Subject: [PATCH 34/74] M2.9: recognize uneven Shaper squares independently of stroke start --- ROADMAP.md | 7 +- crates/engine/src/tests.rs | 28 ++++ crates/geom/src/recognize.rs | 254 ++++++++++++++++++++++++++++++----- crates/tools/src/extra.rs | 4 + 4 files changed, 254 insertions(+), 39 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 66983a40..2505cade 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`, `06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and - isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h. + isolation mode. The Shaper’s rough-square recognition is corrected; Shaper Groups remain pending. 60–90 h. 2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h. @@ -118,6 +118,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **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; circles, ellipses, polygons, lines and scribble deletion remain covered. Shaper Groups (merge/punch) are still pending. - **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. @@ -171,7 +172,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper, the remaining Pen modifier nuances) | 15–20 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition, the remaining Pen modifier nuances; Shaper Groups pending) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | @@ -203,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 | +| Drawing tools | 7 | 85% | rough-square Shaper recognition corrected; remaining: Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c97b572b..37db76a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -731,6 +731,34 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() { assert!(s.doc().unwrap().doc.node(id).is_none()); } +#[test] +fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for (kind, x, y) in [ + (PointerKind::Down, 150.0, 100.0), + (PointerKind::Drag, 200.0, 105.0), + (PointerKind::Drag, 194.0, 200.0), + (PointerKind::Drag, 110.0, 191.0), + (PointerKind::Drag, 100.0, 100.0), + (PointerKind::Up, 150.0, 100.0), + ] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + let id = *s.doc().unwrap().selection.objects.first().expect("recognized square"); + assert!(matches!( + s.doc().unwrap().doc.node(id).unwrap().kind, + vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { .. }), .. } + )); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_some()); +} + #[test] fn a_panicking_command_rolls_back_instead_of_crashing() { let mut s = session(); diff --git a/crates/geom/src/recognize.rs b/crates/geom/src/recognize.rs index eb4f27e5..e95d391c 100644 --- a/crates/geom/src/recognize.rs +++ b/crates/geom/src/recognize.rs @@ -1,9 +1,9 @@ //! Shape recognition for the Shaper tool: a rough freehand stroke becomes a clean shape. //! //! The stroke is classified by how closed it is, how many corners survive a coarse polyline -//! simplification (Douglas–Peucker at a fraction of the stroke's size) and how well it fills its -//! bounding box. A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it -//! covers). +//! simplification independent of where drawing began, its edge/angle fit and how well it fills +//! its bounding box. Samples are spaced by distance so pointer speed does not bias the fit. +//! A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it covers). use crate::{Point, Rect, Vec2}; @@ -48,30 +48,126 @@ fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 { p.distance(a + ab * t) } -/// Douglas–Peucker simplification. +/// Douglas–Peucker simplification with bounded stack use, even for noisy strokes. fn simplify(pts: &[Point], tol: f64) -> Vec { if pts.len() < 3 { return pts.to_vec(); } - let (a, b) = (pts[0], pts[pts.len() - 1]); - let (i, d) = pts[1..pts.len() - 1] - .iter() - .enumerate() - .map(|(i, p)| (i + 1, dist_to_segment(*p, a, b))) - .fold((0, 0.0), |m, x| if x.1 > m.1 { x } else { m }); - if d <= tol { - return vec![a, b]; + let mut keep = vec![false; pts.len()]; + if let Some(first) = keep.first_mut() { + *first = true; } - let mut left = simplify(&pts[..=i], tol); - let right = simplify(&pts[i..], tol); - left.pop(); - left.extend(right); - left + if let Some(last) = keep.last_mut() { + *last = true; + } + let mut pending = vec![(0, pts.len() - 1)]; + while let Some((start, end)) = pending.pop() { + let Some(window) = pts.get(start..=end) else { continue }; + let (Some(&a), Some(&b)) = (window.first(), window.last()) else { continue }; + let (offset, deviation) = window + .iter() + .enumerate() + .skip(1) + .take(window.len().saturating_sub(2)) + .map(|(i, p)| (i, dist_to_segment(*p, a, b))) + .fold((0, 0.0), |best, candidate| if candidate.1 > best.1 { candidate } else { best }); + if deviation > tol { + let split = start + offset; + if let Some(k) = keep.get_mut(split) { + *k = true; + } + if split > start + 1 { + pending.push((start, split)); + } + if end > split + 1 { + pending.push((split, end)); + } + } + } + pts.iter().zip(keep).filter_map(|(p, keep)| keep.then_some(*p)).collect() +} + +/// Sample by distance, so pointer speed/density cannot bias the shape fit. Bounds and length +/// still come from the complete stroke, but simplification works on at most 256 samples. +fn resample(pts: &[Point], length: f64) -> Vec { + const SAMPLES: usize = 256; + let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return Vec::new() }; + let mut out = vec![first]; + let mut walked = 0.0; + for (&a, &b) in pts.iter().zip(pts.iter().skip(1)) { + let segment = a.distance(b); + if segment <= 0.0 { + continue; + } + while out.len() < SAMPLES - 1 { + let target = length * (out.len() as f64 / (SAMPLES - 1) as f64); + if target > walked + segment { + break; + } + out.push(a + (b - a) * ((target - walked) / segment).clamp(0.0, 1.0)); + } + walked += segment; + } + out.push(last); + out +} + +/// A closed stroke has no special starting corner. Split at distant points and simplify both +/// halves, then discard collinear seam points instead of counting them as polygon vertices. +fn closed_corners(pts: &[Point], tol: f64) -> Vec { + let Some(&first) = pts.first() else { return Vec::new() }; + let start = pts.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i); + let ring: Vec = pts.iter().skip(start).chain(pts.iter().take(start)).copied().collect(); + let Some(&first) = ring.first() else { return Vec::new() }; + let split = ring.iter().enumerate().max_by(|(_, a), (_, b)| a.distance(first).total_cmp(&b.distance(first))).map_or(0, |(i, _)| i); + let (Some(left), Some(right)) = (ring.get(..=split), ring.get(split..)) else { return Vec::new() }; + let mut corners = simplify(left, tol); + corners.pop(); + let other: Vec = right.iter().copied().chain(std::iter::once(first)).collect(); + let mut other = simplify(&other, tol); + other.pop(); + corners.extend(other); + while corners.len() > 3 { + let n = corners.len(); + let redundant = corners.iter().enumerate().find_map(|(i, p)| { + let (&a, &b) = (corners.get((i + n - 1) % n)?, corners.get((i + 1) % n)?); + (dist_to_segment(*p, a, b) <= tol).then_some(i) + }); + let Some(i) = redundant else { break }; + corners.remove(i); + } + corners } fn area(poly: &[Point]) -> f64 { - let n = poly.len(); - (0..n).map(|i| poly[i].to_vec2().cross(poly[(i + 1) % n].to_vec2())).sum::().abs() / 2.0 + let Some(&origin) = poly.first() else { return 0.0 }; + // Translating to the first point avoids cancellation far from the document origin. + poly.iter().zip(poly.iter().skip(1)).map(|(&a, &b)| (a - origin).cross(b - origin)).sum::().abs() / 2.0 +} + +/// Four substantial, approximately perpendicular edges, even with uneven sides and corners. +fn is_rectangle(pts: &[Point], corners: &[Point], size: f64) -> bool { + if corners.len() != 4 || !is_polygonal(pts, corners, size, 0.055) { + return false; + } + right_angles(corners, size) +} + +fn right_angles(corners: &[Point], size: f64) -> bool { + let around: Vec = corners.iter().cycle().take(corners.len() + 2).copied().collect(); + around.windows(3).all(|w| { + let [a, b, c] = w else { return false }; + let (u, v) = (*b - *a, *c - *b); + let (lu, lv) = (u.hypot(), v.hypot()); + lu > 0.03 * size && lv > 0.03 * size && (u.dot(v) / (lu * lv)).abs() < 0.45 + }) +} + +fn axis_aligned(corners: &[Point]) -> bool { + corners.iter().zip(corners.iter().cycle().skip(1)).all(|(&a, &b)| { + let edge = b - a; + edge.x.abs().min(edge.y.abs()) < 0.4 * edge.hypot() + }) } /// Number of sharp direction reversals (turns of more than 120°) along the stroke. @@ -87,22 +183,27 @@ fn reversals(pts: &[Point], min_seg: f64) -> usize { /// Recognise a freehand stroke (document points, in drawing order). pub fn recognize(pts: &[Point]) -> Option { - if pts.len() < 2 { + if pts.len() < 2 || pts.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return None; } let b = bbox(pts); let size = b.width().max(b.height()); - if size < 2.0 { + if size < 2.0 || !size.is_finite() { return None; } let len = path_len(pts); - let (first, last) = (pts[0], pts[pts.len() - 1]); + if !len.is_finite() || len <= 0.0 { + return None; + } + let samples = resample(pts, len); + let pts = samples.as_slice(); + let (&first, &last) = (pts.first()?, pts.last()?); + let chord = first.distance(last); // Scribble: several sharp reversals packed into a small area relative to the stroke length. if reversals(pts, size * 0.08) >= 3 && len > 3.0 * size { return Some(Recognized::Scribble(b)); } // Line: every point near the chord. - let chord = first.distance(last); let max_dev = pts.iter().map(|p| dist_to_segment(*p, first, last)).fold(0.0, f64::max); if chord > 0.5 * size && max_dev < 0.08 * chord.max(1.0) && len < 1.3 * chord { return Some(Recognized::Line { a: first, b: last }); @@ -111,13 +212,11 @@ pub fn recognize(pts: &[Point]) -> Option { if chord > 0.25 * size { return None; } - let mut closed: Vec = pts.to_vec(); - closed.push(first); - let mut corners = simplify(&closed, size * 0.12); - if corners.len() > 1 && corners[0].distance(corners[corners.len() - 1]) < size * 0.15 { - corners.pop(); - } + let corners = closed_corners(pts, size * 0.12); let fill = area(pts) / (b.width() * b.height()).max(1e-9); + if !fill.is_finite() { + return None; + } let center = b.center(); let square = |r: Rect| { let (w, h) = (r.width(), r.height()); @@ -136,13 +235,13 @@ pub fn recognize(pts: &[Point]) -> Option { let rot = (top - center).atan2().to_degrees() + 90.0; Some(Recognized::Polygon { center, radius, sides: 3, rotation: rot }) } - 4 if fill > 0.86 => Some(Recognized::Rectangle(square(b))), + 4 if is_rectangle(pts, &corners, size) && axis_aligned(&corners) => Some(Recognized::Rectangle(square(b))), // A diamond: a square standing on a corner. - 4 if (0.4..0.62).contains(&fill) => { + 4 if (0.4..0.62).contains(&fill) && is_rectangle(pts, &corners, size) => { let radius = corners.iter().map(|p| p.distance(center)).sum::() / 4.0; Some(Recognized::Polygon { center, radius, sides: 4, rotation: 0.0 }) } - n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size) => { + n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size, 0.02) => { let radius = corners.iter().map(|p| p.distance(center)).sum::() / n as f64; Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: 0.0 }) } @@ -153,11 +252,16 @@ pub fn recognize(pts: &[Point]) -> Option { /// Do the stroke's points hug the straight edges between `corners` (rather than bulging like an /// ellipse)? -fn is_polygonal(pts: &[Point], corners: &[Point], size: f64) -> bool { - let n = corners.len(); - let dev: f64 = pts.iter().map(|p| (0..n).map(|i| dist_to_segment(*p, corners[i], corners[(i + 1) % n])).fold(f64::MAX, f64::min)).sum::() +fn is_polygonal(pts: &[Point], corners: &[Point], size: f64, tolerance: f64) -> bool { + if pts.is_empty() || corners.len() < 3 { + return false; + } + let dev: f64 = pts + .iter() + .map(|p| corners.iter().zip(corners.iter().cycle().skip(1)).map(|(&a, &b)| dist_to_segment(*p, a, b)).fold(f64::MAX, f64::min)) + .sum::() / pts.len() as f64; - dev < 0.02 * size + dev < tolerance * size } #[cfg(test)] @@ -199,6 +303,27 @@ mod tests { assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares"); } + #[test] + fn rough_squares_are_rectangles_from_any_starting_point() { + let cases = [ + [Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], + [Point::new(0.0, 8.0), Point::new(94.0, 0.0), Point::new(100.0, 95.0), Point::new(7.0, 100.0)], + ]; + for vertices in cases { + for wobble in [0.5, 4.0] { + let mut stroke = wobbly(&vertices, 24, wobble); + stroke.pop(); + for start in [0, 5, 12, 20, 30, 47, 60, 79] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0] + Vec2::new(2.0, -1.0)); + assert!(matches!(recognize(&closed), Some(Recognized::Rectangle(_))), "wobble {wobble}, start {start}: {:?}", recognize(&closed)); + stroke.rotate_right(start); + } + } + } + } + #[test] fn ellipse_circle_triangle_hexagon_line() { assert!( @@ -220,6 +345,63 @@ mod tests { assert!(matches!(recognize(&line), Some(Recognized::Line { .. }))); } + #[test] + fn circles_and_real_polygons_keep_their_identity_across_stroke_seams() { + for (rx, ry) in [(50.0, 50.0), (80.0, 40.0), (50.0, 40.0)] { + let mut stroke = circle(Point::new(100.0, 100.0), rx, ry, 192); + stroke.pop(); + for start in [0, 13, 37, 62, 109] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0]); + assert!(matches!(recognize(&closed), Some(Recognized::Ellipse(_))), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed)); + stroke.rotate_right(start); + } + } + for sides in [3, 5, 6, 8] { + let corners: Vec = (0..sides) + .map(|i| { + let a = std::f64::consts::TAU * i as f64 / sides as f64; + Point::new(100.0 + 80.0 * a.cos(), 100.0 + 80.0 * a.sin()) + }) + .collect(); + let mut stroke = wobbly(&corners, 24, 1.0); + stroke.pop(); + for start in [0, 9, 17, 29, 45] { + stroke.rotate_left(start); + let mut closed = stroke.clone(); + closed.push(stroke[0]); + assert!( + matches!(recognize(&closed), Some(Recognized::Polygon { sides: n, .. }) if n == sides), + "{sides} sides, seam {start}: {:?}", + recognize(&closed) + ); + stroke.rotate_right(start); + } + } + } + + #[test] + fn square_fit_ignores_sampling_density_and_document_offset() { + let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0); + for scale in [0.25, 1.0, 20.0] { + let shifted: Vec = stroke.iter().map(|p| Point::new(1e9 + p.x * scale, -1e9 + p.y * scale)).collect(); + assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle(_))), "scale {scale}: {:?}", recognize(&shifted)); + } + let dense: Vec = stroke.iter().enumerate().flat_map(|(i, p)| std::iter::repeat_n(*p, if i < 24 { 100 } else { 1 })).collect(); + assert_eq!(recognize(&stroke), recognize(&dense)); + stroke.extend(std::iter::repeat_n(*stroke.last().unwrap(), 10_000)); + assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle(_)))); + } + + #[test] + fn non_finite_and_overflowing_strokes_are_not_shapes() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(recognize(&[Point::new(0.0, 0.0), Point::new(bad, 100.0)]), None); + } + assert_eq!(recognize(&[Point::new(-f64::MAX, 0.0), Point::new(f64::MAX, 100.0)]), None); + } + #[test] fn scribble_and_noise() { let z: Vec = (0..40).map(|i| Point::new(100.0 + (i % 2) as f64 * 60.0, 100.0 + i as f64 * 2.0)).collect(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 66511f88..050c4bf0 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -244,6 +244,10 @@ impl Tool for ShaperTool { vec![] } PointerKind::Up => { + // A fast final segment may arrive only on release; it still closes the stroke. + if !self.points.is_empty() && self.points.last().is_none_or(|p| *p != ev.pos) { + self.points.push(ev.pos); + } let pts = std::mem::take(&mut self.points); let r = |x: vectorcraft_geom::Rect| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height() }); match recognize(&pts) { From 7e82dbc05b972d8b32b68e7b31444a87ed8e6d48 Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 22:50:49 +0100 Subject: [PATCH 35/74] M2.9: add editable Shaper groups and shape orientation steps --- ROADMAP.md | 8 +- crates/doc/src/hit.rs | 16 +- crates/doc/src/lib.rs | 1 + crates/doc/src/node.rs | 13 +- crates/doc/src/shaper.rs | 41 ++ crates/engine/src/cmd/create.rs | 27 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/object.rs | 6 +- crates/engine/src/cmd/shaper.rs | 771 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 45 +- crates/engine/src/tests.rs | 55 +- crates/engine/src/tests_shaper.rs | 347 +++++++++++++ crates/geom/src/recognize.rs | 216 ++++++-- crates/tools/src/builder.rs | 8 +- crates/tools/src/extra.rs | 35 +- crates/tools/src/lib.rs | 1 + crates/tools/src/select.rs | 7 + crates/ui-egui/src/canvas.rs | 24 +- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + docs/shaper.md | 18 + 26 files changed, 1575 insertions(+), 93 deletions(-) create mode 100644 crates/doc/src/shaper.rs create mode 100644 crates/engine/src/cmd/shaper.rs create mode 100644 crates/engine/src/tests_shaper.rs create mode 100644 docs/shaper.md diff --git a/ROADMAP.md b/ROADMAP.md index 2505cade..2ebde4a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`, `06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and - isolation mode. The Shaper’s rough-square recognition is corrected; Shaper Groups remain pending. 60–90 h. + isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h. 2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h. @@ -118,7 +118,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected). - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **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; circles, ellipses, polygons, lines and scribble deletion remain covered. Shaper Groups (merge/punch) are still pending. + - **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. @@ -172,7 +172,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition, the remaining Pen modifier nuances; Shaper Groups pending) | 15–20 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | @@ -204,7 +204,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | -| Drawing tools | 7 | 85% | rough-square Shaper recognition corrected; remaining: Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 10–15 | +| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 | diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index a741c109..ac054639 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -44,6 +44,8 @@ impl Hit { /// Options for hit testing. #[derive(Clone, Copy, Debug)] pub struct HitOptions { + /// The isolated construction container, whose retained Shaper sources are hittable. + pub scope: Option, /// Tolerance in document units (screen tolerance / zoom). pub tol: f64, /// Outline mode: only outlines are hittable. @@ -58,7 +60,7 @@ pub struct HitOptions { impl Default for HitOptions { fn default() -> Self { - Self { tol: 3.0, outline: false, path_only: false, type_path_only: false } + Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None } } } @@ -70,6 +72,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { /// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost /// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind). pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option) -> Vec { + let opt = HitOptions { scope: scope.or(opt.scope), ..opt }; let mut tops: Vec = vec![]; // Each round leaves out the objects found so far (and everything inside them). while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) { @@ -131,13 +134,20 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) { return None; } - for c in children.iter().rev() { + for (i, c) in children.iter().enumerate().rev() { + if parent.shaper.is_some() { + let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope); + if (i == 0) != construction { + continue; + } + } // Hidden, locked and template sublayers block clicks like top-level layers do. if !c.visible || c.locked || c.is_template() || skip(c.id) { continue; } // (An envelope's content sits where it was, not where the envelope draws it.) - if !edits_contents(c) + if !(edits_contents(c) + || c.shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope)) && let Some(b) = c.reach_bounds() && !b.inflate(opt.tol, opt.tol).contains(p) { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b5650487..2fa402b9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -33,6 +33,7 @@ mod reach; pub mod recolor; pub mod selection; pub mod setup; +pub mod shaper; pub mod slices; pub mod style_libs; pub mod swatches; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 53d5c757..c6b69904 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -472,6 +472,9 @@ pub struct Node { /// Graph object: the group's children are generated from this spec (Object → Graph). #[serde(default, skip_serializing_if = "Option::is_none")] pub graph: Option>, + /// Editable Shaper composition; the original art is retained in its source child. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shaper: Option>, pub kind: NodeKind, /// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps /// that style's look: editing its appearance or transparency breaks the link. @@ -536,6 +539,7 @@ impl Node { trace: None, wrap: None, graph: None, + shaper: None, kind, graphic_style: None, attrs: None, @@ -707,6 +711,7 @@ impl Node { } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children .iter() + .skip(usize::from(self.shaper.is_some())) .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), NodeKind::Text(t) => self.projected(t.bounds()), @@ -738,7 +743,11 @@ impl Node { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { // The container's own strokes paint around its members. let o = self.appearance.outset(); - children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o)) + children + .iter() + .skip(usize::from(self.shaper.is_some())) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) + .map(|b| b.inflate(o, o)) } NodeKind::Blend { children, spec } => { let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())); @@ -965,7 +974,7 @@ impl Node { } } NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children.iter().filter(|c| c.visible) { + for c in children.iter().skip(usize::from(self.shaper.is_some())).filter(|c| c.visible) { c.push_clip_shapes(text, out); } } diff --git a/crates/doc/src/shaper.rs b/crates/doc/src/shaper.rs new file mode 100644 index 00000000..1ed7fe71 --- /dev/null +++ b/crates/doc/src/shaper.rs @@ -0,0 +1,41 @@ +//! Persistent Shaper edits over retained source paths. Source indices (not document ids) keep +//! recipes valid when a group is copied. Selector anchors use source-relative bounding boxes, +//! so the intended face or stroke follows source art when it moves or resizes. +use crate::Appearance; +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::Point; + +pub const SOURCES: &str = "Shaper Sources"; +pub const FILL_PREFIX: &str = "Shaper Fill "; +pub const STROKE_PREFIX: &str = "Shaper Stroke "; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ShaperSpec { + pub faces: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FaceSelector { + pub sources: Vec, + pub anchor: Point, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FaceEdit { + pub selector: FaceSelector, + pub erase: bool, + pub erase_edges: bool, + pub paint_source: Option, + pub merge: Option, + pub paint: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EdgeEdit { + pub source: usize, + pub sides: [Vec; 2], + pub anchor: Point, + pub erase: bool, + pub paint: Option, +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dc73c63c..2daa9a96 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -9,8 +9,16 @@ use super::*; pub fn specs() -> Vec { vec![ - cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle), - cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse), + cmd!( + "shape.rectangle", + "Rectangle", + [], + None, + "{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}", + has_doc, + rectangle + ), + cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse), cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon), cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star), cmd!( @@ -163,6 +171,17 @@ fn rect_of(p: &Value, cmd: &str) -> Result { Ok(Rect::new(x, y, x + w, y + h).abs()) } +fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result { + let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 }; + if !rotation.is_finite() { + return Err(bad(cmd, "rotation must be finite")); + } + if rotation == 0.0 { + return Ok(Affine::translate(r.origin().to_vec2())); + } + Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0))) +} + fn rectangle(s: &mut Session, p: &Value) -> Result { let r = rect_of(p, "shape.rectangle")?; let radius = f64_or(p, "radius", 0.0).max(0.0); @@ -171,7 +190,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result { h: r.height(), radii: [radius; 4], kinds: Default::default(), - xf: Affine::translate(r.origin().to_vec2()), + xf: rect_transform(r, p, "shape.rectangle")?, }; let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" }; add_art(s, label, path_kind(live.to_path(), Some(live)), None) @@ -179,7 +198,7 @@ fn rectangle(s: &mut Session, p: &Value) -> Result { fn ellipse(s: &mut Session, p: &Value) -> Result { let r = rect_of(p, "shape.ellipse")?; - let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) }; + let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? }; add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c0115bcf..182518bd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -59,6 +59,7 @@ pub mod rasterfx; mod recolor; pub mod recovery; mod select; +pub(crate) mod shaper; pub(crate) mod slices; mod stroke; mod style; @@ -231,6 +232,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); + v.extend(shaper::specs()); v.extend(brushsym::specs()); v.extend(patterncmds::specs()); v.extend(prefscmds::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 65a9951a..ffc9b007 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -722,7 +722,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result { if st.doc.node(id).is_none_or(|n| !n.is_container()) { return Err(bad("object.isolate", "only groups and layers can be isolated")); } - st.isolation = Some(id); + let isolation = st.doc.node(id).filter(|n| n.shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + st.isolation = Some(isolation); st.selection.clear(); st.revision += 1; ok() @@ -732,7 +733,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); - st.selection.set([i]); + let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.shaper.is_some())).unwrap_or(i); + st.selection.set([target]); } st.revision += 1; ok() diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs new file mode 100644 index 00000000..f9e5e52b --- /dev/null +++ b/crates/engine/src/cmd/shaper.rs @@ -0,0 +1,771 @@ +//! Shaper compositions are groups of generated fills/strokes with transparent, editable source +//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in +//! the same undo transaction. Rendering and exporters only see ordinary groups and paths. +use super::*; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, sync::Arc}; +use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOURCES, STROKE_PREFIX, ShaperSpec}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _}; +use vectorcraft_pathops as po; +use vectorcraft_tools::builder::{self as b, BuilderMap, Hit}; + +const MAX_SOURCES: usize = 64; +const MAX_POINTS: usize = 4096; +const MAX_EDITS: usize = 8192; + +pub fn specs() -> Vec { + vec![ + cmd!( + "shaper.scribble", + "Shaper", + [], + None, + "{points: [[x,y]…], ids?: [id…] (default: nearby art), tolerance?: pt (4)} interior scribbles remove fills; scribbles through several regions merge with the origin fill; ending outside removes fills and boundary strokes; outside scribbles erase stroke pieces. Originals remain editable → {id, regions, strokes}", + has_doc, + scribble + ), + cmd!( + "shaper.select", + "Select Shaper Face", + [], + None, + "{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original → {id}", + has_doc, + select + ), + cmd!( + "shaper.release", + "Release Shaper Group", + ["Object", "Shaper"], + None, + "{id? (default: selection)} restore the original paths → {ids}", + has_doc, + release + ), + cmd!( + "shaper.expand", + "Expand Shaper Group", + ["Object", "Shaper"], + None, + "{id? (default: selection)} keep the visible result as ordinary paths → {id}", + has_doc, + expand + ), + ] +} + +fn source_group(n: &Node) -> Option<&Node> { + n.shaper.as_ref()?; + n.children()?.first().map(AsRef::as_ref).filter(|g| g.name.as_deref() == Some(SOURCES)) +} +fn sources(n: &Node) -> Vec> { + source_group(n).and_then(Node::children).cloned().unwrap_or_default() +} +fn finite(p: Point) -> bool { + p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1e9 && p.y.abs() <= 1e9 +} + +fn map_of(src: &[Arc]) -> Result { + if src.is_empty() || src.len() > MAX_SOURCES { + return Err(bad("shaper.scribble", "Shaper needs 1–64 source paths")); + } + let mut count = 0usize; + let mut shapes = vec![]; + for (i, n) in src.iter().enumerate() { + let (mut path, rule) = b::node_outline(n).ok_or_else(|| bad("shaper.scribble", "use paths or compound paths"))?; + count = count.saturating_add(path.subpaths.iter().map(|s| s.segment_count()).sum::()); + if count > 4096 || path.subpaths.iter().flat_map(|s| &s.anchors).any(|a| !finite(a.p) || !finite(a.h_in) || !finite(a.h_out)) { + return Err(bad("shaper.scribble", "source paths are too large for Shaper")); + } + if !n.visible { + path = PathData::default(); + } else if !n.appearance.fill_paint().is_none() { + for sub in path.subpaths.iter_mut().filter(|s| !s.closed) { + sub.closed = po::encloses_area(sub); + } + } + shapes.push(po::Shape::new(path, rule, i as u64)); + } + let arrangement = po::shape_builder(&shapes, true); + let bounded = |pieces: Vec| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect(); + let regions = arrangement + .regions + .into_iter() + .filter_map(|r| { + let bounds = r.path.bounds()?; + let path = r.path.to_bezpath(); + Some((r, path, bounds)) + }) + .collect(); + let map = BuilderMap { shapes, regions, edges: bounded(arrangement.edges), lines: bounded(arrangement.lines) }; + if map.regions.is_empty() && map.lines.is_empty() && map.edges.is_empty() { + return Err(bad("shaper.scribble", "these paths are too degenerate for Shaper")); + } + Ok(map) +} + +fn relative(src: &[Arc], i: usize, p: Point) -> Point { + let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO); + Point::new((p.x - b.x0) / b.width().max(1e-9), (p.y - b.y0) / b.height().max(1e-9)) +} +fn absolute(src: &[Arc], i: usize, p: Point) -> Point { + let b = src.get(i).and_then(|n| n.geometric_bounds()).unwrap_or(Rect::ZERO); + Point::new(b.x0 + p.x * b.width(), b.y0 + p.y * b.height()) +} +fn face_selector(map: &BuilderMap, src: &[Arc], i: usize) -> Option { + let (r, _, bounds) = map.regions.get(i)?; + let source = *r.sources.first()?; + let point = po::interior_point(&r.path).unwrap_or(bounds.center()); + Some(FaceSelector { sources: r.sources.clone(), anchor: relative(src, source, point) }) +} +fn selected_face(map: &BuilderMap, src: &[Arc], selector: &FaceSelector) -> Option { + let point = absolute(src, *selector.sources.first()?, selector.anchor); + map.regions + .iter() + .enumerate() + .filter(|(_, (r, _, _))| r.sources == selector.sources) + .min_by(|(_, a), (_, b)| { + let distance = |r: &b::Face| if r.1.winding(point) != 0 { 0.0 } else { r.2.center().distance(point) }; + distance(a).total_cmp(&distance(b)) + }) + .map(|(i, _)| i) +} +fn piece_point(path: &PathData) -> Option { + use vectorcraft_geom::ParamCurve as _; + let sub = path.subpaths.first()?; + (sub.segment_count() > 0).then(|| sub.segment(sub.segment_count() / 2).eval(0.5)) +} +fn pieces(map: &BuilderMap) -> Vec<&po::Shape> { + map.edges.iter().chain(&map.lines).map(|(p, _)| p).collect() +} +fn side_sources(map: &BuilderMap, p: &po::Shape) -> [Vec; 2] { + let mut sides = map.sides(p).map(|i| i.and_then(|i| map.regions.get(i)).map_or(vec![], |r| r.0.sources.clone())); + sides.sort(); + sides +} +fn edge_selector(map: &BuilderMap, src: &[Arc], piece: &po::Shape) -> Option { + let source = usize::try_from(piece.key).ok()?; + Some(EdgeEdit { source, sides: side_sources(map, piece), anchor: relative(src, source, piece_point(&piece.path)?), erase: true, paint: None }) +} +fn selected_edge(map: &BuilderMap, src: &[Arc], edit: &EdgeEdit) -> Option { + let point = absolute(src, edit.source, edit.anchor); + pieces(map) + .iter() + .enumerate() + .filter(|(_, p)| p.key == edit.source as u64 && side_sources(map, p) == edit.sides) + .filter_map(|(i, p)| p.path.nearest(point).map(|(_, _, _, _, distance)| (i, distance))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|p| p.0) +} +fn face_edits<'a>(map: &BuilderMap, src: &[Arc], spec: &'a ShaperSpec) -> BTreeMap { + spec.faces.iter().filter_map(|e| selected_face(map, src, &e.selector).map(|i| (i, e))).collect() +} +fn fill_source(src: &[Arc], r: &po::Region) -> Option { + r.sources.iter().rev().copied().find(|i| src.get(*i).is_some_and(|s| s.visible && !s.appearance.fill_paint().is_none())) +} +/// Regions separated only by covered strokes are one visible face. A visible line +/// through a circle still separates two faces, even though their fills have the same source. +fn visible_components(map: &BuilderMap, src: &[Arc], edits: &BTreeMap) -> Vec { + fn root(parents: &[usize], mut i: usize) -> usize { + for _ in 0..parents.len() { + let Some(&next) = parents.get(i) else { return i }; + if next == i { + return i; + } + i = next; + } + i + } + let owner = |i: usize| { + let edit = edits.get(&i); + if edit.is_some_and(|e| e.erase || e.paint.as_ref().is_some_and(|a| a.fill_paint().is_none())) { + return None; + } + edit.and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(src, &r.0))) + }; + let mut parents: Vec = (0..map.regions.len()).collect(); + for piece in pieces(map) { + let [Some(a), Some(b)] = map.sides(piece) else { continue }; + let (Some(left), Some(right)) = (owner(a), owner(b)) else { continue }; + let merge = |i| edits.get(&i).and_then(|e| e.merge); + let merged = merge(a).is_some() && merge(a) == merge(b); + let covered = left as u64 > piece.key && right as u64 > piece.key; + let unstroked = src.get(piece.key as usize).is_none_or(|n| !n.visible || n.appearance.stroke_paint().is_none()); + if (left == right || merged) && (merged || covered || unstroked) { + let (a, b) = (root(&parents, a), root(&parents, b)); + if let Some(parent) = parents.get_mut(a) { + *parent = b; + } + } + } + (0..parents.len()).map(|i| root(&parents, i)).collect() +} + +fn fill_style(mut a: Appearance) -> Appearance { + a.items.retain(AppearanceItem::is_fill); + a +} +fn stroke_style(mut a: Appearance) -> Appearance { + a.items.retain(|i| !i.is_fill()); + a +} + +/// Join compatible strokes at two-way junctions. Branches stay separate; a line +/// crossing a circle must not turn into the circle's outline at the intersection. +fn join_strokes(nodes: Vec>) -> Vec> { + const TOL: f64 = 1e-5; + type End = (usize, bool); + let mut buckets: BTreeMap<(i64, i64), Vec<(End, Point)>> = BTreeMap::new(); + let key = |p: Point| finite(p).then(|| ((p.x / TOL).floor() as i64, (p.y / TOL).floor() as i64)); + for (i, n) in nodes.iter().enumerate() { + let Some(sp) = n.path_data().filter(|p| p.subpaths.len() == 1).and_then(|p| p.subpaths.first()).filter(|p| !p.closed) else { continue }; + for (last, a) in [(false, sp.anchors.first()), (true, sp.anchors.last())] { + if let Some(a) = a + && let Some(key) = key(a.p) + { + buckets.entry(key).or_default().push(((i, last), a.p)); + } + } + } + let mut connections: BTreeMap = BTreeMap::new(); + for entries in buckets.values() { + for &(end, p) in entries { + let Some((x, y)) = key(p) else { continue }; + let mut near = vec![]; + for dx in -1..=1 { + for dy in -1..=1 { + if let Some(entries) = buckets.get(&(x + dx, y + dy)) { + near.extend(entries.iter().filter(|(_, q)| p.distance(*q) <= TOL).map(|(end, _)| *end)); + } + } + } + if near.len() != 2 { + continue; + } + let Some(other) = near.into_iter().find(|e| *e != end) else { continue }; + let Some((a, b)) = nodes.get(end.0).zip(nodes.get(other.0)) else { continue }; + if a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend { + connections.insert(end, other); + } + } + } + let mut seen = std::collections::BTreeSet::new(); + let mut out = vec![]; + // Open chains first, then closed loops. This avoids beginning in a chain's middle. + let starts = nodes + .iter() + .enumerate() + .flat_map(|(i, _)| [(i, false), (i, true)]) + .filter(|e| !connections.contains_key(e)) + .chain(nodes.iter().enumerate().map(|(i, _)| (i, false))); + for start in starts { + if seen.contains(&start.0) { + continue; + } + if let Some(node) = nodes.get(start.0).filter(|n| n.path_data().is_none_or(|p| p.subpaths.len() != 1)) { + seen.insert(start.0); + out.push(node.clone()); + continue; + } + let mut end = start; + let mut prototype = start.0; + let mut path = vectorcraft_geom::SubPath::default(); + loop { + if !seen.insert(end.0) { + break; + } + prototype = prototype.min(end.0); + let Some(mut next) = nodes.get(end.0).and_then(|n| n.path_data()).and_then(|p| p.subpaths.first()).cloned() else { break }; + if end.1 { + next.reverse(); + } + if path.anchors.is_empty() { + path = next; + } else { + if let Some((a, b)) = path.anchors.last_mut().zip(next.anchors.first()) { + *a = vectorcraft_geom::Anchor::with_handles(a.p, a.h_in, b.h_out); + } + path.anchors.extend(next.anchors.into_iter().skip(1)); + } + let Some(&next) = connections.get(&(end.0, !end.1)) else { break }; + if next == start { + if let Some(last) = path.anchors.pop() + && let Some(first) = path.anchors.first_mut() + { + *first = vectorcraft_geom::Anchor::with_handles(first.p, last.h_in, first.h_out); + } + path.closed = true; + break; + } + end = next; + } + if let Some(mut node) = nodes.get(prototype).cloned() { + if let Some(p) = Arc::make_mut(&mut node).path_data_mut() { + *p = PathData::new(vec![path]); + } + out.push(node); + } + } + out +} + +fn generated(d: &mut Document, src: &[Arc], spec: &ShaperSpec, map: &BuilderMap) -> Result>> { + if spec.faces.len() + spec.edges.len() > MAX_EDITS { + return Err(bad("shaper.scribble", "too many Shaper edits")); + } + let edits = face_edits(map, src, spec); + let mut fills: BTreeMap<(Option, usize), Vec> = BTreeMap::new(); + for (i, (r, _, _)) in map.regions.iter().enumerate() { + let edit = edits.get(&i); + if edit.is_some_and(|e| e.erase) { + continue; + } + if edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, r)).is_none() { + continue; + } + let merge = edit.and_then(|e| e.merge); + fills.entry((merge, if merge.is_some() { 0 } else { i })).or_default().push(i); + } + let mut out = vec![]; + for indices in fills.values() { + let Some(&i) = indices.first() else { continue }; + let Some((region, _, _)) = map.regions.get(i) else { continue }; + let edit = edits.get(&i); + let Some(source) = edit.and_then(|e| e.paint_source).or_else(|| fill_source(src, region)).and_then(|i| src.get(i)) else { continue }; + let path = po::unite_all(&indices.iter().filter_map(|i| map.regions.get(*i).map(|r| (&r.0.path, FillRule::NonZero))).collect::>()); + let style = edit.and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone()); + let mut n = Node::path(d.alloc_id(), path, fill_style(style)); + n.opacity = source.opacity; + n.blend = source.blend; + n.name = Some(format!("{FILL_PREFIX}{i}")); + out.push(Arc::new(n)); + } + let edge_edits: BTreeMap = spec.edges.iter().filter_map(|e| selected_edge(map, src, e).map(|i| (i, e))).collect(); + let mut strokes = vec![]; + for (i, piece) in pieces(map).into_iter().enumerate() { + let sides = map.sides(piece); + let merge = |r: Option| r.and_then(|r| edits.get(&r)).and_then(|e| e.merge); + let merged_inside = merge(sides[0]).is_some() && merge(sides[0]) == merge(sides[1]); + // An exiting scribble removes the exposed stroke around the deleted area. + // Its shared boundary with surviving art still closes that art's contour. + let erased_boundary = + sides.iter().any(Option::is_some) && sides.iter().all(|r| r.is_none_or(|r| edits.get(&r).is_some_and(|e| e.erase && e.erase_edges))); + let occluded = sides + .iter() + .all(|r| r.and_then(|r| map.regions.get(r)).and_then(|r| fill_source(src, &r.0)).is_some_and(|source| source as u64 > piece.key)); + if merged_inside || erased_boundary || occluded || edge_edits.get(&i).is_some_and(|e| e.erase) { + continue; + } + let Some(source) = src.get(piece.key as usize) else { continue }; + let style = edge_edits.get(&i).and_then(|e| e.paint.clone()).unwrap_or_else(|| source.appearance.clone()); + if style.stroke_paint().is_none() { + continue; + } + let mut n = Node::path(d.alloc_id(), piece.path.clone(), stroke_style(style)); + n.opacity = source.opacity; + n.blend = source.blend; + n.name = Some(format!("{STROKE_PREFIX}{i}")); + strokes.push(Arc::new(n)); + } + out.extend(join_strokes(strokes)); + Ok(out) +} + +/// Re-evaluate only groups whose original art changed. Errors reach Session::edit's rollback. +pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> { + let mut ids = vec![]; + d.walk(|n| { + if n.shaper.is_some() { + ids.push(n.id) + } + }); + for id in ids { + let Some(current) = d.node(id).cloned() else { continue }; + let Some(old) = before.node(id) else { continue }; + let src = sources(¤t); + let source_changed = sources(old) != src; + let output_changed = current.children().is_some_and(|children| { + children + .iter() + .skip(1) + .any(|n| old.children().and_then(|c| c.iter().find(|o| o.id == n.id)).is_some_and(|o| o.appearance != n.appearance)) + }); + if !source_changed && !output_changed { + continue; + } + if src.is_empty() { + d.remove(id)?; + continue; + } + let map = map_of(&src)?; + let mut spec = current.shaper.as_deref().cloned().unwrap_or_default(); + let previous = sources(old); + if previous.iter().map(|s| s.id).ne(src.iter().map(|s| s.id)) { + // Source insertion, deletion or reordering updates the index-based recipes. + let index = |i: usize| previous.get(i).and_then(|old| src.iter().position(|n| n.id == old.id)); + spec.faces.retain_mut(|e| { + let Some(indices) = e.selector.sources.iter().map(|i| index(*i)).collect::>>() else { return false }; + let paint_source = match e.paint_source { + Some(i) => { + let Some(i) = index(i) else { return false }; + Some(i) + } + None => None, + }; + e.selector.sources = indices; + e.selector.sources.sort(); + e.paint_source = paint_source; + true + }); + spec.edges.retain_mut(|e| { + let Some(source) = index(e.source) else { return false }; + for side in &mut e.sides { + let Some(indices) = side.iter().map(|i| index(*i)).collect::>>() else { return false }; + *side = indices; + side.sort(); + } + e.sides.sort(); + e.source = source; + true + }); + } + if output_changed { + for n in current.children().into_iter().flatten().skip(1) { + let Some(o) = old.children().and_then(|c| c.iter().find(|o| o.id == n.id)) else { continue }; + if o.appearance == n.appearance { + continue; + } + if let Some(i) = n.name.as_deref().and_then(|s| s.strip_prefix(FILL_PREFIX)).and_then(|s| s.parse::().ok()) { + let existing = face_edits(&map, &src, &spec); + let merge = existing.get(&i).and_then(|e| e.merge); + let components = visible_components(&map, &src, &existing); + let related: Vec = if let Some(m) = merge { + existing.iter().filter(|(_, e)| e.merge == Some(m)).map(|(i, _)| *i).collect() + } else { + components.iter().enumerate().filter(|(_, c)| Some(*c) == components.get(i)).map(|(i, _)| i).collect() + }; + for i in related { + let Some(selector) = face_selector(&map, &src, i) else { continue }; + if let Some(e) = spec.faces.iter_mut().rev().find(|e| e.selector == selector) { + e.paint = Some(n.appearance.clone()); + } else { + spec.faces.push(FaceEdit { + selector, + erase: false, + erase_edges: false, + paint_source: None, + merge, + paint: Some(n.appearance.clone()), + }); + } + } + } else if n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX)) { + // One joined output may contain pieces from several original paths. + for piece in pieces(&map) { + let contained = piece + .path + .subpaths + .iter() + .flat_map(|s| &s.anchors) + .all(|a| n.path_data().and_then(|p| p.nearest(a.p)).is_some_and(|p| p.4 < 1e-5)) + && piece_point(&piece.path).is_some_and(|point| n.path_data().and_then(|p| p.nearest(point)).is_some_and(|p| p.4 < 1e-5)); + if contained && let Some(mut e) = edge_selector(&map, &src, piece) { + e.erase = false; + e.paint = Some(n.appearance.clone()); + spec.edges.push(e); + } + } + } + } + } + let mut children = current.children().and_then(|c| c.first()).cloned().into_iter().collect::>(); + let old_ids: BTreeMap<&str, NodeId> = + current.children().into_iter().flatten().skip(1).filter_map(|n| n.name.as_deref().map(|name| (name, n.id))).collect(); + for mut n in generated(d, &src, &spec, &map)? { + if let Some(id) = n.name.as_deref().and_then(|name| old_ids.get(name)) { + Arc::make_mut(&mut n).id = *id; + } + children.push(n); + } + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + n.shaper = Some(Box::new(spec)); + if let Some(ch) = n.children_mut() { + *ch = children; + } + } + Ok(()) +} + +fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result> { + let st = s.doc()?; + let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance); + let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::>()); + let expand = supplied.is_none() && st.selection.objects.len() < 2; + let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() }); + let eligible: Vec = ids + .into_iter() + .filter(|id| { + st.doc.is_editable(*id) + && st.doc.node(*id).is_some_and(|n| { + !matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) + && (n.shaper.is_some() || b::node_outline(n).is_some()) + }) + }) + .collect(); + let bounds = |id| st.doc.node(id).and_then(Node::geometric_bounds).map(|b| b.inflate(tolerance, tolerance)); + let overlaps = |a: Rect, b: Rect| a.intersect(b).area() > 0.0; + let mut ids: Vec = eligible.iter().copied().filter(|id| bounds(*id).is_some_and(|b| overlaps(b, area))).collect(); + if expand { + // A protruding line must still be split at the shape it passes through, even when + // the scribble itself never enters that shape. Include the connected neighbourhood. + loop { + let next: Vec = eligible + .iter() + .copied() + .filter(|id| { + !ids.contains(id) + && bounds(*id).is_some_and(|bb| { + ids.iter() + .any(|other| st.doc.parent_of(*other) == st.doc.parent_of(*id) && bounds(*other).is_some_and(|b| overlaps(bb, b))) + }) + }) + .collect(); + if next.is_empty() { + break; + } + ids.extend(next); + if ids.len() > MAX_SOURCES { + return Err(bad("shaper.scribble", "too many overlapping paths for Shaper")); + } + } + } + let ids = b::sorted_roots(&st.doc, &ids); + let parent = ids.first().and_then(|id| st.doc.position(*id)).map(|p| p.0); + if ids.iter().any(|id| st.doc.position(*id).map(|p| p.0) != parent) { + return Err(bad("shaper.scribble", "overlapping Shaper paths must be in the same layer or group")); + } + Ok(ids) +} + +fn scribble(s: &mut Session, p: &Value) -> Result { + const C: &str = "shaper.scribble"; + let input = p + .get("points") + .and_then(Value::as_array) + .filter(|a| !a.is_empty() && a.len() <= MAX_POINTS) + .ok_or_else(|| bad(C, "points must contain 1–4096 coordinates"))?; + let pts = input + .iter() + .map(|v| point_param(&json!({"p": v}), "p").filter(|p| finite(*p)).ok_or_else(|| bad(C, "invalid point"))) + .collect::>>()?; + let tolerance = f64_or(p, "tolerance", 4.0); + if !tolerance.is_finite() || !(0.01..=1000.0).contains(&tolerance) { + return Err(bad(C, "invalid tolerance")); + } + let step = (tolerance / 2.0).max(0.5); + let sample_count = pts.windows(2).map(|w| (w[0].distance(w[1]) / step).ceil()).sum::(); + if sample_count > 65_536.0 { + return Err(bad(C, "scribble is too long")); + } + let points = b::sample_polyline(&pts, step); + let roots = candidates(s, p, &points, tolerance)?; + if roots.is_empty() { + return Ok(json!({"id": null, "regions": 0, "strokes": 0})); + } + let mut src = vec![]; + let mut spec = ShaperSpec::default(); + let mut next_merge = 1u64; + for id in &roots { + let n = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; + if let Some(previous) = n.shaper.as_deref() { + let offset = src.len(); + let merge_offset = next_merge; + for mut e in previous.faces.clone() { + for i in &mut e.selector.sources { + *i = i.saturating_add(offset); + } + e.paint_source = e.paint_source.map(|i| i.saturating_add(offset)); + e.merge = e.merge.map(|i| i.saturating_add(merge_offset)); + next_merge = next_merge.max(e.merge.unwrap_or(0).saturating_add(1)); + spec.faces.push(e); + } + for mut e in previous.edges.clone() { + e.source = e.source.saturating_add(offset); + for side in &mut e.sides { + for i in side { + *i = i.saturating_add(offset); + } + } + spec.edges.push(e); + } + src.extend(sources(n)); + } else { + src.push(Arc::new(n.clone())); + } + } + let map = map_of(&src)?; + let edits = face_edits(&map, &src, &spec); + let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some(); + let components = visible_components(&map, &src, &edits); + let faces: std::collections::BTreeSet = + points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect(); + let touched: Vec = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect(); + let first = points.first().copied().unwrap_or(Point::ZERO); + let last = points.last().copied().unwrap_or(first); + let origin = map.region_at(first).filter(|i| filled(*i)); + let exits = map.region_at(last).is_none(); + let merge = origin.is_some() && faces.len() > 1 && !exits; + let paint_source = + origin.and_then(|i| edits.get(&i).and_then(|e| e.paint_source).or_else(|| map.regions.get(i).and_then(|r| fill_source(&src, &r.0)))); + let origin_paint = origin.and_then(|i| edits.get(&i).and_then(|e| e.paint.clone())); + for &i in &touched { + if let Some(selector) = face_selector(&map, &src, i) { + spec.faces.push(FaceEdit { + selector, + erase: !merge, + erase_edges: exits, + paint_source, + merge: merge.then_some(next_merge), + paint: origin_paint.clone(), + }); + } + } + let mut strokes = 0; + if touched.is_empty() { + let mut piece_ids = vec![]; + for point in &points { + let index = match map.hit(*point, Some(tolerance)) { + Some(Hit::Line(i)) => Some(map.edges.len() + i), + Some(Hit::Edge(i)) => Some(i), + _ => None, + }; + if let Some(i) = index + && !piece_ids.contains(&i) + { + piece_ids.push(i); + } + } + for i in piece_ids { + if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) { + spec.edges.push(e); + strokes += 1; + } + } + } + if touched.is_empty() && strokes == 0 { + return Ok(json!({"id": null, "regions": 0, "strokes": 0})); + } + let id = s.edit("Shaper", |d, sel| { + let top = *roots.last().ok_or_else(|| bad(C, "no source art"))?; + let (parent, index, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let earlier = roots.iter().filter(|id| **id != top && d.position(**id).is_some_and(|p| p.1 < index)).count(); + let source_id = d.alloc_id(); + let mut source = Node::group(source_id, src.clone()); + source.name = Some(SOURCES.into()); + source.opacity = 0.0; + let mut children = vec![Arc::new(source)]; + children.extend(generated(d, &src, &spec, &map)?); + let id = d.alloc_id(); + let mut group = Node::group(id, children); + group.name = Some("Shaper Group".into()); + group.shaper = Some(Box::new(spec)); + for id in &roots { + d.remove(*id)?; + } + d.insert(parent, index.saturating_sub(earlier), group)?; + sel.set([id]); + Ok(id) + })?; + s.doc_mut()?.isolation = None; + Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes})) +} + +fn group_at(d: &Document, point: Point, construction: bool) -> Option { + let mut groups = vec![]; + d.walk(|n| { + if n.shaper.is_some() + && d.is_editable(n.id) + && (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() }) + .is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) + { + groups.push(n.id) + } + }); + groups.last().copied() +} +fn select(s: &mut Session, p: &Value) -> Result { + let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?; + let st = s.doc()?; + let source = bool_or(p, "source", false); + let Some(group) = group_at(&st.doc, point, source) else { return Ok(json!({"id": null})) }; + let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?; + let target = if source { + sources(node) + .iter() + .rev() + .find(|n| { + b::node_outline(n) + .is_some_and(|(path, _)| path.to_bezpath().winding(point) != 0 || path.nearest(point).is_some_and(|(_, _, _, _, d)| d < 6.0)) + }) + .map(|n| n.id) + .unwrap_or(group) + } else if st.selection.contains(group) { + node.children() + .into_iter() + .flatten() + .skip(1) + .find(|n| { + n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) && n.path_data().is_some_and(|p| p.to_bezpath().winding(point) != 0) + }) + .map_or(group, |n| n.id) + } else { + group + }; + let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten(); + s.select(|_, sel| sel.set([target]))?; + if let Some(id) = isolation { + s.doc_mut()?.isolation = Some(id); + } + Ok(json!({"id": target.0})) +} +fn target(s: &Session, p: &Value) -> Result { + let id = id_param(p, "id") + .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) + .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + Ok(id) +} +fn release(s: &mut Session, p: &Value) -> Result { + let id = target(s, p)?; + let ids = s.edit("Release Shaper Group", |d, sel| { + let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?; + let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; + d.remove(id)?; + let mut ids = vec![]; + for (i, n) in original.into_iter().enumerate() { + ids.push(d.insert(parent, index + i, (*n).clone())?); + } + sel.set(ids.iter().copied()); + Ok(ids) + })?; + s.doc_mut()?.isolation = None; + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) +} +fn expand(s: &mut Session, p: &Value) -> Result { + let id = target(s, p)?; + s.edit("Expand Shaper Group", |d, sel| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + n.shaper = None; + n.name = None; + if let Some(children) = n.children_mut() + && children.first().is_some_and(|c| c.name.as_deref() == Some(SOURCES)) + { + children.remove(0); + } + sel.set([id]); + Ok(()) + })?; + s.doc_mut()?.isolation = None; + Ok(json!({"id": id.0})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 874cb74d..0b4ebd76 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1100,27 +1100,30 @@ impl Session { let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); let result = match f(doc, &mut st.selection) { - Ok(v) => { - // Text Wrap: area type follows its wrap objects; then threads re-flow. - cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); - // Opacity-mask editing: the mask follows its art on the editing layer. - if st.doc.mask_edit.is_some() { - cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); + Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) { + Err(e) => Err(e), + Ok(()) => { + // Text Wrap: area type follows its wrap objects; then threads re-flow. + cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); + // Opacity-mask editing: the mask follows its art on the editing layer. + if st.doc.mask_edit.is_some() { + cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); + } + // Threaded text re-flows when any of its frames changed. + if !st.doc.text_threads.is_empty() { + cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); + } + // Asset Export: assets let go of deleted art. + if !st.doc.assets.is_empty() { + Arc::make_mut(&mut st.doc).prune_assets(); + } + if doc_sane(&st.doc, &st.selection) { + Ok(v) + } else { + Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) + } } - // Threaded text re-flows when any of its frames changed. - if !st.doc.text_threads.is_empty() { - cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); - } - // Asset Export: assets let go of deleted art. - if !st.doc.assets.is_empty() { - Arc::make_mut(&mut st.doc).prune_assets(); - } - if doc_sane(&st.doc, &st.selection) { - Ok(v) - } else { - Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) - } - } + }, Err(e) => Err(e), }; match result { @@ -1491,6 +1494,8 @@ mod tests_saveoptions; #[cfg(test)] mod tests_scalestrokes; #[cfg(test)] +mod tests_shaper; +#[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_smartguides; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37db76a3..cd7e65b9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,7 @@ use serde_json::json; use super::*; +use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; fn session() -> Session { @@ -695,7 +696,7 @@ fn snap_to_pixel_rounds_drawing_and_moves() { } #[test] -fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() { +fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_punch_fills() { let mut s = session(); let v = ViewInfo { smart_guides: false, ..Default::default() }; s.select_tool("shaper", v).unwrap(); @@ -725,10 +726,14 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() { "{:?}", n.kind_label() ); - // A zig-zag over it deletes it. + // A zig-zag inside it clears its fill while retaining the editable original and stroke. let zig: Vec<(f64, f64)> = (0..30).map(|i| (150.0 + (i % 2) as f64 * 80.0, 120.0 + i as f64 * 2.0)).collect(); stroke(&mut s, &zig); - assert!(s.doc().unwrap().doc.node(id).is_none()); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &n); + let group = s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap(); + assert!(group.shaper.is_some()); + assert!(group.children().unwrap().iter().skip(1).all(|n| n.appearance.fill_paint().is_none())); + assert!(group.children().unwrap().iter().skip(1).any(|n| !n.appearance.stroke_paint().is_none())); } #[test] @@ -759,6 +764,50 @@ fn shaper_square_started_mid_edge_closes_on_pointer_release_and_undoes_once() { assert!(s.doc().unwrap().doc.node(id).is_some()); } +#[test] +fn shaper_creates_upright_or_inverted_live_triangles_with_one_undo_step() { + let v = ViewInfo { smart_guides: false, ..Default::default() }; + for direction in [0.0_f64, 180.0] { + let mut s = session(); + s.select_tool("shaper", v).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let corners: Vec = (0..3) + .map(|i| { + let angle = (direction + 7.0 - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0; + Point::new(200.0 + 70.0 * angle.cos(), 200.0 + 70.0 * angle.sin()) + }) + .collect(); + let mut pts = vec![]; + for i in 0..3 { + for j in 0..20 { + pts.push(corners[i] + (corners[(i + 1) % 3] - corners[i]) * (j as f64 / 20.0)); + } + } + // Begin midway along an edge; the release closes the final segment. + pts.rotate_left(7); + s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].x, pts[0].y), v).unwrap(); + for p in &pts[1..] { + s.pointer(&PointerEvent::new(PointerKind::Drag, p.x, p.y), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, pts[0].x, pts[0].y), v).unwrap(); + let id = *s.doc().unwrap().selection.objects.first().expect("recognized triangle"); + let node = s.doc().unwrap().doc.node(id).unwrap().clone(); + let vectorcraft_doc::NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Polygon { sides: 3, xf, .. }), .. } = &node.kind else { + panic!("expected live triangle, got {:?}", node.kind_label()); + }; + let [a, b, c, d, _, _] = xf.as_coeffs(); + let (sin, cos) = direction.to_radians().sin_cos(); + for (actual, expected) in [(a, cos), (b, sin), (c, -sin), (d, cos)] { + assert!((actual - expected).abs() < 1e-12, "direction {direction}, transform {xf:?}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap(), &node); + } +} + #[test] fn a_panicking_command_rolls_back_instead_of_crashing() { let mut s = session(); diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs new file mode 100644 index 00000000..a3275608 --- /dev/null +++ b/crates/engine/src/tests_shaper.rs @@ -0,0 +1,347 @@ +use super::*; +use serde_json::json; +use vectorcraft_doc::shaper::{FILL_PREFIX, SOURCES, STROKE_PREFIX}; +use vectorcraft_doc::{LiveShape, Node, NodeKind}; +use vectorcraft_geom::{Point, Shape as _}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + s +} +fn rectangle(s: &mut Session, x: f64, fill: &str) -> NodeId { + s.execute("paint.setFill", &json!({"color": fill, "ids": []})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap(); + NodeId(id) +} +fn scribble(s: &mut Session, pts: &[[f64; 2]]) -> NodeId { + NodeId(s.execute("shaper.scribble", &json!({"points":pts})).unwrap()["id"].as_u64().unwrap()) +} +fn node(s: &Session, id: NodeId) -> &Node { + s.doc().unwrap().doc.node(id).unwrap() +} +fn fill(s: &Session, id: NodeId, x: f64, y: f64) -> Option { + node(s, id) + .children() + .unwrap() + .iter() + .filter(|n| n.name.as_deref() != Some(SOURCES)) + .rev() + .find(|n| { + n.name.as_deref().is_some_and(|n| n.starts_with(FILL_PREFIX)) + && n.path_data().is_some_and(|p| p.to_bezpath().winding(Point::new(x, y)) != 0) + }) + .map(|n| n.appearance.fill_paint().label()) +} +fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool { + node(s, id).children().unwrap().iter().filter(|n| n.name.as_deref() != Some(SOURCES)).any(|n| { + n.name.as_deref().is_some_and(|n| n.starts_with(STROKE_PREFIX)) + && !n.appearance.stroke_paint().is_none() + && n.path_data().and_then(|p| p.nearest(Point::new(x, y))).is_some_and(|(_, _, _, _, d)| d < 0.1) + }) +} + +#[test] +fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() { + for reverse in [false, true] { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let originals = [node(&s, a).clone(), node(&s, b).clone()]; + let undo = s.doc().unwrap().history.undo.len(); + let mut pts = vec![[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0], [75.0, 80.0], [125.0, 85.0]]; + if reverse { + pts.reverse(); + } + let g = scribble(&mut s, &pts); + let color = if reverse { "#0000ff" } else { "#ff0000" }; + for x in [25.0, 75.0, 125.0] { + assert_eq!(fill(&s, g, x, 50.0).as_deref(), Some(color)); + } + for n in &originals { + assert_eq!(node(&s, n.id), n); + } + assert_eq!(node(&s, g).children().unwrap()[0].name.as_deref(), Some(SOURCES)); + assert!(!stroke(&s, g, 50.0, 50.0)); + assert!(stroke(&s, g, 0.0, 50.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).is_none()); + for n in &originals { + assert_eq!(node(&s, n.id), n); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some(color)); + } +} + +#[test] +fn shaper_interior_punch_removes_the_whole_visible_front_face() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let originals = [node(&s, a).clone(), node(&s, b).clone()]; + let g = scribble(&mut s, &[[65.0, 20.0], [85.0, 40.0], [65.0, 60.0], [85.0, 80.0]]); + assert_eq!(fill(&s, g, 75.0, 50.0), None); + assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000")); + assert_eq!(fill(&s, g, 125.0, 50.0), None); + assert!(stroke(&s, g, 50.0, 50.0) && stroke(&s, g, 150.0, 50.0)); + for n in &originals { + assert_eq!(node(&s, n.id), n); + } +} + +#[test] +fn shaper_exiting_scribble_erases_only_touched_fill_and_boundary_strokes() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]); + assert_eq!(fill(&s, g, 125.0, 50.0), None); + assert!(!stroke(&s, g, 150.0, 50.0)); + assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000")); + assert_eq!(fill(&s, g, 75.0, 50.0), None); + assert!(stroke(&s, g, 0.0, 50.0)); +} + +#[test] +fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() { + let mut s = session(); + s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap(); + let circle = NodeId(s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()); + let line = NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap()); + let original_line = node(&s, line).clone(); + let g = scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]); + assert!(!stroke(&s, g, 75.0, 150.0)); + assert!(stroke(&s, g, 150.0, 150.0)); + assert!(stroke(&s, g, 225.0, 150.0)); + assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); + assert_eq!(node(&s, line), &original_line); + assert!(matches!(node(&s, circle).kind, NodeKind::Path { live: Some(LiveShape::Ellipse { .. }), .. })); + let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]); + assert!(!stroke(&s, g, 225.0, 150.0)); + assert!(stroke(&s, g, 150.0, 150.0)); + assert_eq!(node(&s, line), &original_line); +} + +#[test] +fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + let b = rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + s.execute("object.move", &json!({"ids":[a.0],"dx":20,"dy":10})).unwrap(); + assert_eq!(fill(&s, g, 30.0, 15.0).as_deref(), Some("#ff0000")); + assert_eq!(node(&s, a).geometric_bounds().unwrap().x0, 20.0); + s.execute("select.set", &json!({"ids":[a.0]})).unwrap(); + s.execute("object.setBounds", &json!({"width":120.0,"height":110.0,"reference":0})).unwrap(); + assert!(matches!(node(&s, a).kind, NodeKind::Path { live: Some(LiveShape::Rectangle { .. }), .. })); + let doc = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + assert_eq!(back.layers, doc.layers); + let originals = [node(&s, a).clone(), node(&s, b).clone()]; + s.execute("shaper.release", &json!({"id":g.0})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).is_none()); + for n in &originals { + assert_eq!(node(&s, n.id), n); + } +} + +#[test] +fn shaper_face_paint_survives_source_edits_and_expand_discards_only_sources() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + s.execute("shaper.select", &json!({"point":[75,50]})).unwrap(); + s.execute("paint.setFill", &json!({"color":"#00ff00"})).unwrap(); + s.execute("object.move", &json!({"ids":[a.0],"dx":5,"dy":0})).unwrap(); + assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); + s.execute("shaper.expand", &json!({"id":g.0})).unwrap(); + assert!(node(&s, g).shaper.is_none()); + assert!(s.doc().unwrap().doc.node(a).is_none()); + assert_eq!(fill(&s, g, 75.0, 50.0).as_deref(), Some("#00ff00")); +} + +#[test] +fn shaper_double_click_selects_an_original_for_construction_editing() { + let mut s = session(); + let a = rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + let requests = s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 25.0, 50.0), v).unwrap(); + assert!(requests.iter().any(|r| matches!(r, UiRequest::SwitchTool(tool) if tool == "selection"))); + s.select_tool("selection", v).unwrap(); + assert_eq!(s.tool_id(), "selection"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().isolation, Some(node(&s, g).children().unwrap()[0].id)); + assert!(s.doc().unwrap().doc.is_editable(a)); + s.execute("object.move", &json!({"dx":10,"dy":0})).unwrap(); + assert_eq!(fill(&s, g, 15.0, 50.0).as_deref(), Some("#ff0000")); + s.execute("object.exitIsolation", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); +} + +#[test] +fn shaper_lines_keep_free_angles_with_or_without_shift() { + for shift in [false, true] { + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + for (kind, x, y) in [(PointerKind::Down, 30.0, 40.0), (PointerKind::Drag, 130.0, 70.0), (PointerKind::Up, 230.0, 100.0)] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Default::default() }), v).unwrap(); + } + let n = node(&s, s.doc().unwrap().selection.objects[0]); + assert!(matches!(n.kind,NodeKind::Path{live:Some(LiveShape::Line{a,b}),..} if a==Point::new(30.0,40.0) && b==Point::new(230.0,100.0))); + } +} + +#[test] +fn shaper_rejects_oversized_or_invalid_gestures_without_changing_art() { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + for params in [json!({"points":vec![[1.0,1.0];4097]}), json!({"points":[[1,1],[2,2]],"tolerance":0}), json!({"points":[[1e100,0]]})] { + assert!(s.execute("shaper.scribble", ¶ms).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } +} + +#[test] +fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() { + use vectorcraft_doc::hit::{HitOptions, hit_test}; + let mut s = session(); + s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap(); + s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap(); + let rotate = |x: f64, y: f64| { + let (x, y) = (x - 150.0, y - 150.0); + Point::new(150.0 + (x + y) / 2.0f64.sqrt(), 150.0 + (y - x) / 2.0f64.sqrt()) + }; + let (a, b) = (rotate(50.0, 150.0), rotate(250.0, 150.0)); + let line = NodeId(s.execute("shape.line", &json!({"x1":a.x,"y1":a.y,"x2":b.x,"y2":b.y})).unwrap()["id"].as_u64().unwrap()); + let original = node(&s, line).clone(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + s.select_tool("shaper", v).unwrap(); + for offset in [0.0, 150.0] { + let points = [(60.0, 140.0), (70.0, 160.0), (80.0, 140.0), (90.0, 160.0)]; + for (i, (x, y)) in points.into_iter().enumerate() { + let p = rotate(x + offset, y); + let kind = if i == 0 { + PointerKind::Down + } else if i == 3 { + PointerKind::Up + } else { + PointerKind::Drag + }; + s.pointer(&PointerEvent::new(kind, p.x, p.y), v).unwrap(); + } + } + let g = s.doc().unwrap().selection.objects[0]; + assert!(node(&s, g).shaper.is_some()); + assert_eq!(node(&s, line), &original); + assert!(stroke(&s, g, 150.0, 150.0)); + assert_eq!(fill(&s, g, 150.0, 130.0).as_deref(), Some("#ffff00")); + let sources = node(&s, g).children().unwrap()[0].id; + let doc = &s.doc().unwrap().doc; + for x in [75.0, 225.0] { + let p = rotate(x, 150.0); + assert!(!stroke(&s, g, p.x, p.y)); + assert!(!node(&s, g).geometric_bounds().unwrap().contains(p)); + assert!(!node(&s, g).visual_bounds().unwrap().contains(p)); + assert!(hit_test(doc, p, HitOptions { tol: 0.5, ..Default::default() }).is_none()); + assert_eq!(hit_test(doc, p, HitOptions { tol: 0.5, scope: Some(sources), ..Default::default() }).unwrap().leaf, line); + } + let p = rotate(75.0, 150.0); + s.execute("shaper.select", &json!({"point":[p.x,p.y],"source":true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![line]); +} + +#[test] +fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() { + for exits in [false, true] { + let mut s = session(); + let mut originals = vec![]; + for (x, y, fill) in [(100.0, 20.0, "#ff0000"), (20.0, 130.0, "#ffffff"), (170.0, 130.0, "#ffff00")] { + s.execute("paint.setFill", &json!({"color":fill,"ids":[]})).unwrap(); + let id = NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":y,"width":200,"height":200})).unwrap()["id"].as_u64().unwrap()); + originals.push(node(&s, id).clone()); + } + let mut points = vec![[290.0, 210.0], [330.0, 225.0], [290.0, 240.0], [330.0, 255.0]]; + if exits { + points.push([410.0, 260.0]); + } + let g = scribble(&mut s, &points); + for (x, y) in [(330.0, 230.0), (190.0, 230.0), (250.0, 180.0)] { + assert_eq!(fill(&s, g, x, y), None, "all of the visible yellow face is erased, including covered intersections"); + } + assert_eq!(stroke(&s, g, 370.0, 230.0), !exits); + for angle in [145.0_f64, 160.0, 180.0, 200.0, 220.0, 240.0, 260.0, 280.0] { + let (sin, cos) = angle.to_radians().sin_cos(); + assert!(stroke(&s, g, 270.0 + 100.0 * cos, 230.0 + 100.0 * sin), "shared curve remains complete at {angle}°, exiting {exits}"); + } + if exits { + // The two new corners must be actual path joins, rather than meeting butt caps. + for corner in [Point::new(298.975, 134.288), Point::new(195.0, 296.144)] { + assert!( + node(&s, g) + .children() + .unwrap() + .iter() + .filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))) + .flat_map(|n| n.path_data().unwrap().subpaths.iter()) + .flat_map(|sp| &sp.anchors) + .any(|a| a.p.distance(corner) < 0.1 && a.has_in() && a.has_out()), + "joined corner at {corner:?}" + ); + } + } + assert_eq!(fill(&s, g, 110.0, 230.0).as_deref(), Some("#ffffff")); + assert_eq!(fill(&s, g, 200.0, 80.0).as_deref(), Some("#ff0000")); + for n in originals { + assert_eq!(node(&s, n.id), &n); + } + } +} + +#[test] +fn shaper_visible_dividing_stroke_keeps_fill_erasure_on_one_side() { + let mut s = session(); + s.execute("paint.setFill", &json!({"color":"#ffff00","ids":[]})).unwrap(); + s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap(); + s.execute("shape.line", &json!({"x1":100,"y1":150,"x2":200,"y2":150})).unwrap(); + let g = scribble(&mut s, &[[140.0, 125.0], [160.0, 135.0], [140.0, 140.0], [160.0, 145.0]]); + assert_eq!(fill(&s, g, 150.0, 130.0), None); + assert_eq!(fill(&s, g, 150.0, 170.0).as_deref(), Some("#ffff00")); + assert!(stroke(&s, g, 150.0, 150.0)); +} + +#[test] +fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() { + let mut s = session(); + let mut ids = vec![]; + for (x, color) in [(0, "#ff0000"), (50, "#ffff00")] { + s.execute("paint.setFill", &json!({"color":color,"ids":[]})).unwrap(); + ids.push(NodeId(s.execute("shape.ellipse", &json!({"x":x,"y":0,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap())); + } + let g = scribble(&mut s, &[[20.0, 40.0], [70.0, 45.0], [30.0, 50.0], [125.0, 55.0], [75.0, 60.0], [125.0, 65.0]]); + let outlines: Vec<_> = + node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect(); + assert_eq!(outlines.len(), 1); + assert!(outlines[0].path_data().unwrap().subpaths[0].closed); + let outline = outlines[0].id; + s.execute("paint.setStroke", &json!({"color":"#00ff00","ids":[outline.0]})).unwrap(); + s.execute("object.move", &json!({"ids":[ids[0].0],"dx":10,"dy":0})).unwrap(); + let outlines: Vec<_> = + node(&s, g).children().unwrap().iter().filter(|n| n.name.as_deref().is_some_and(|s| s.starts_with(STROKE_PREFIX))).collect(); + assert_eq!(outlines.len(), 1); + assert_eq!(outlines[0].appearance.stroke_paint().label(), "#00ff00"); + assert!(outlines[0].path_data().unwrap().subpaths[0].closed); + for id in ids { + assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00"); + } +} diff --git a/crates/geom/src/recognize.rs b/crates/geom/src/recognize.rs index e95d391c..a149bfc7 100644 --- a/crates/geom/src/recognize.rs +++ b/crates/geom/src/recognize.rs @@ -5,7 +5,7 @@ //! its bounding box. Samples are spaced by distance so pointer speed does not bias the fit. //! A zig-zag stroke with several reversals is a scribble (the Shaper deletes what it covers). -use crate::{Point, Rect, Vec2}; +use crate::{Affine, Point, Rect, Vec2}; /// What the Shaper recognised. #[derive(Clone, Debug, PartialEq)] @@ -14,10 +14,16 @@ pub enum Recognized { a: Point, b: Point, }, - /// Axis-aligned rectangle (squares snap when the sides are within 10%). - Rectangle(Rect), - /// Axis-aligned ellipse in `rect` (circles snap when the axes are within 10%). - Ellipse(Rect), + /// Rectangle at a 45° step (squares snap when the sides are within 10%). + Rectangle { + rect: Rect, + rotation: f64, + }, + /// Ellipse at a 45° step (circles snap when the axes are within 10%). + Ellipse { + rect: Rect, + rotation: f64, + }, /// Regular polygon (triangle, pentagon, hexagon…): centre, radius, side count and the rotation /// (degrees) of the first vertex from straight up. Polygon { @@ -163,11 +169,59 @@ fn right_angles(corners: &[Point], size: f64) -> bool { }) } -fn axis_aligned(corners: &[Point]) -> bool { - corners.iter().zip(corners.iter().cycle().skip(1)).all(|(&a, &b)| { - let edge = b - a; - edge.x.abs().min(edge.y.abs()) < 0.4 * edge.hypot() - }) +/// Fit all vertices, so neither the starting point nor drawing direction picks the orientation. +fn polygon_rotation(corners: &[Point], step: f64) -> f64 { + let Some(&origin) = corners.first() else { return 0.0 }; + let n = corners.len() as f64; + let center = origin + corners.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n); + let period = 360.0 / n; + let error = |rotation: f64| { + corners + .iter() + .map(|p| { + let angle = (*p - center).atan2().to_degrees() + 90.0; + let delta = (angle - rotation).rem_euclid(period); + delta.min(period - delta).powi(2) + }) + .sum::() + }; + [0.0, 90.0, 180.0, 270.0].into_iter().filter(|r| r % step == 0.0).min_by(|a, b| error(*a).total_cmp(&error(*b))).unwrap_or(0.0) +} + +fn rectangle_rotation(corners: &[Point]) -> f64 { + let (sin, cos) = corners.iter().zip(corners.iter().cycle().skip(1)).fold((0.0, 0.0), |(sin, cos), (&a, &b)| { + let angle = (b - a).atan2() * 4.0; + (sin + angle.sin(), cos + angle.cos()) + }); + (sin.atan2(cos).to_degrees() / 4.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0) +} + +fn ellipse_rotation(pts: &[Point]) -> f64 { + let Some(&origin) = pts.first() else { return 0.0 }; + let n = pts.len() as f64; + let center = origin + pts.iter().fold(Vec2::ZERO, |sum, p| sum + (*p - origin) / n); + let (xx, yy, xy) = pts.iter().fold((0.0, 0.0, 0.0), |(xx, yy, xy), p| { + let v = *p - center; + (xx + v.x * v.x, yy + v.y * v.y, xy + v.x * v.y) + }); + // A near-circle has no useful orientation; keep it upright rather than following noise. + if (xx - yy).hypot(2.0 * xy) < 0.1 * (xx + yy) { + return 0.0; + } + ((2.0 * xy).atan2(xx - yy).to_degrees() / 2.0 / 45.0).round().mul_add(45.0, 0.0).rem_euclid(180.0) +} + +/// Dimensions in the fitted frame, but the centre stays in document coordinates. +fn oriented_bounds(pts: &[Point], rotation: f64) -> Rect { + if rotation == 0.0 { + return bbox(pts); + } + let origin = pts.first().copied().unwrap_or(Point::ZERO); + let rotate = Affine::rotate(rotation.to_radians()); + let inverse = Affine::rotate(-rotation.to_radians()); + let local: Vec = pts.iter().map(|p| inverse * (*p - origin).to_point()).collect(); + let b = bbox(&local); + Rect::from_center_size(origin + (rotate * b.center()).to_vec2(), (b.width(), b.height())) } /// Number of sharp direction reversals (turns of more than 120°) along the stroke. @@ -195,13 +249,21 @@ pub fn recognize(pts: &[Point]) -> Option { if !len.is_finite() || len <= 0.0 { return None; } + let original = pts; let samples = resample(pts, len); let pts = samples.as_slice(); let (&first, &last) = (pts.first()?, pts.last()?); let chord = first.distance(last); // Scribble: several sharp reversals packed into a small area relative to the stroke length. - if reversals(pts, size * 0.08) >= 3 && len > 3.0 * size { - return Some(Recognized::Scribble(b)); + if reversals(pts, size * 0.08) >= 2 && len > 1.5 * size { + // An equilateral triangle turns 120° at its vertices: drawing noise can make all + // three turns just exceed the reversal threshold. Its closed, straight edges and + // substantial area distinguish it from a deletion scribble. + let corners = closed_corners(pts, size * 0.12); + let triangle = chord <= 0.25 * size && corners.len() == 3 && area(pts) > 0.2 * size * size && is_polygonal(pts, &corners, size, 0.055); + if !triangle { + return Some(Recognized::Scribble(b)); + } } // Line: every point near the chord. let max_dev = pts.iter().map(|p| dist_to_segment(*p, first, last)).fold(0.0, f64::max); @@ -230,23 +292,21 @@ pub fn recognize(pts: &[Point]) -> Option { match corners.len() { 3 => { let radius = corners.iter().map(|p| p.distance(center)).sum::() / 3.0; - // Rotation of the vertex nearest straight up (y grows downward). - let top = corners.iter().copied().min_by(|a, b| a.y.total_cmp(&b.y)).unwrap_or(center); - let rot = (top - center).atan2().to_degrees() + 90.0; - Some(Recognized::Polygon { center, radius, sides: 3, rotation: rot }) + Some(Recognized::Polygon { center, radius, sides: 3, rotation: polygon_rotation(&corners, 180.0) }) } - 4 if is_rectangle(pts, &corners, size) && axis_aligned(&corners) => Some(Recognized::Rectangle(square(b))), - // A diamond: a square standing on a corner. - 4 if (0.4..0.62).contains(&fill) && is_rectangle(pts, &corners, size) => { - let radius = corners.iter().map(|p| p.distance(center)).sum::() / 4.0; - Some(Recognized::Polygon { center, radius, sides: 4, rotation: 0.0 }) + 4 if is_rectangle(pts, &corners, size) => { + let rotation = rectangle_rotation(&corners); + Some(Recognized::Rectangle { rect: square(oriented_bounds(original, rotation)), rotation }) } n @ 5..=8 if fill > 0.6 && fill < 0.85 && is_polygonal(pts, &corners, size, 0.02) => { let radius = corners.iter().map(|p| p.distance(center)).sum::() / n as f64; - Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: 0.0 }) + Some(Recognized::Polygon { center, radius, sides: n as u32, rotation: if n == 6 { polygon_rotation(&corners, 90.0) } else { 0.0 } }) + } + _ => { + let rotation = ellipse_rotation(pts); + let rect = oriented_bounds(original, rotation); + (area(pts) / (rect.width() * rect.height()).max(1e-9) > 0.6).then(|| Recognized::Ellipse { rect: square(rect), rotation }) } - _ if fill > 0.6 => Some(Recognized::Ellipse(square(b))), - _ => None, } } @@ -296,10 +356,10 @@ mod tests { #[test] fn rough_rectangle_and_square() { let r = wobbly(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)], 20, 2.0); - let Some(Recognized::Rectangle(b)) = recognize(&r) else { panic!("{:?}", recognize(&r)) }; + let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&r) else { panic!("{:?}", recognize(&r)) }; assert!((b.width() - 200.0).abs() < 8.0 && (b.height() - 100.0).abs() < 8.0); let s = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 95.0), Point::new(0.0, 95.0)], 20, 1.0); - let Some(Recognized::Rectangle(b)) = recognize(&s) else { panic!() }; + let Some(Recognized::Rectangle { rect: b, .. }) = recognize(&s) else { panic!() }; assert!((b.width() - b.height()).abs() < 1e-9, "near-squares snap to squares"); } @@ -317,7 +377,11 @@ mod tests { stroke.rotate_left(start); let mut closed = stroke.clone(); closed.push(stroke[0] + Vec2::new(2.0, -1.0)); - assert!(matches!(recognize(&closed), Some(Recognized::Rectangle(_))), "wobble {wobble}, start {start}: {:?}", recognize(&closed)); + assert!( + matches!(recognize(&closed), Some(Recognized::Rectangle { .. })), + "wobble {wobble}, start {start}: {:?}", + recognize(&closed) + ); stroke.rotate_right(start); } } @@ -327,10 +391,10 @@ mod tests { #[test] fn ellipse_circle_triangle_hexagon_line() { assert!( - matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse(r)) if r.width() > 150.0 && r.height() < 100.0) + matches!(recognize(&circle(Point::new(100.0, 100.0), 80.0, 40.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if r.width() > 150.0 && r.height() < 100.0) ); assert!( - matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse(r)) if (r.width() - r.height()).abs() < 1e-9) + matches!(recognize(&circle(Point::new(100.0, 100.0), 50.0, 48.0, 72)), Some(Recognized::Ellipse { rect: r, .. }) if (r.width() - r.height()).abs() < 1e-9) ); let tri = wobbly(&[Point::new(100.0, 0.0), Point::new(200.0, 170.0), Point::new(0.0, 170.0)], 25, 1.5); assert!(matches!(recognize(&tri), Some(Recognized::Polygon { sides: 3, rotation, .. }) if rotation.abs() < 5.0), "{:?}", recognize(&tri)); @@ -354,7 +418,7 @@ mod tests { stroke.rotate_left(start); let mut closed = stroke.clone(); closed.push(stroke[0]); - assert!(matches!(recognize(&closed), Some(Recognized::Ellipse(_))), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed)); + assert!(matches!(recognize(&closed), Some(Recognized::Ellipse { .. })), "ellipse {rx}/{ry}, seam {start}: {:?}", recognize(&closed)); stroke.rotate_right(start); } } @@ -381,17 +445,105 @@ mod tests { } } + #[test] + fn rough_triangles_snap_upright_or_upside_down() { + for direction in [0.0_f64, 180.0] { + for tilt in [-12.0_f64, -3.0, 4.0, 11.0] { + let corners: Vec = (0..3) + .map(|i| { + let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0; + Point::new(200.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin()) + }) + .collect(); + for reversed in [false, true] { + let mut stroke = wobbly(&corners, 24, 1.5); + stroke.pop(); + if reversed { + stroke.reverse(); + } + for seam in [0, 9, 28, 51] { + stroke.rotate_left(seam); + let mut closed = stroke.clone(); + closed.push(stroke[0]); + let Some(Recognized::Polygon { sides: 3, rotation, .. }) = recognize(&closed) else { + panic!("direction {direction}, tilt {tilt}, seam {seam}: {:?}", recognize(&closed)); + }; + assert_eq!(rotation, direction, "tilt {tilt}, seam {seam}, reversed {reversed}"); + stroke.rotate_right(seam); + } + } + } + } + } + + #[test] + fn recognized_lines_keep_their_free_angle() { + for (dx, dy) in [(120.0, 7.0), (-130.0, -9.0), (8.0, 90.0), (-6.0, -140.0), (60.0, 50.0), (-50.0, -60.0)] { + let first = Point::new(200.0, 300.0); + let last = first + Vec2::new(dx, dy); + let stroke: Vec = (0..=30).map(|i| first + (last - first) * (i as f64 / 30.0)).collect(); + assert_eq!(recognize(&stroke), Some(Recognized::Line { a: first, b: last })); + } + } + + #[test] + fn tilted_rectangles_ellipses_and_hexagons_snap_to_their_angle_steps() { + for direction in [0.0_f64, 45.0, 90.0, 135.0] { + for tilt in [-7.0_f64, 7.0] { + let xf = Affine::translate((200.0, 300.0)) * Affine::rotate((direction + tilt).to_radians()); + let corners: Vec = + [(-90.0, -40.0), (90.0, -40.0), (90.0, 40.0), (-90.0, 40.0)].into_iter().map(|p| xf * Point::from(p)).collect(); + let stroke = wobbly(&corners, 24, 1.0); + let Some(Recognized::Rectangle { rect, rotation }) = recognize(&stroke) else { + panic!("rectangle {direction}: {:?}", recognize(&stroke)) + }; + assert_eq!(rotation % 45.0, 0.0); + assert!(rect.center().distance(Point::new(200.0, 300.0)) < 4.0); + assert!((rect.width().max(rect.height()) - 180.0).abs() < 15.0); + let ellipse: Vec = circle(Point::ZERO, 90.0, 40.0, 192).into_iter().map(|p| xf * p).collect(); + let Some(Recognized::Ellipse { rect, rotation }) = recognize(&ellipse) else { + panic!("ellipse {direction}: {:?}", recognize(&ellipse)) + }; + assert_eq!(rotation, direction); + assert!((rect.width() - 180.0).abs() < 4.0 && (rect.height() - 80.0).abs() < 4.0); + } + } + for direction in [0.0_f64, 90.0] { + for tilt in [-7.0_f64, 7.0] { + let corners: Vec = (0..6) + .map(|i| { + let a = (direction + tilt - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 6.0; + Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin()) + }) + .collect(); + let stroke = wobbly(&corners, 24, 0.5); + let Some(Recognized::Polygon { sides: 6, rotation, .. }) = recognize(&stroke) else { panic!("hexagon: {:?}", recognize(&stroke)) }; + assert_eq!(rotation, direction); + } + } + // Even sideways input can only produce an upright or inverted triangle. + for direction in [83.0_f64, 97.0, 263.0, 277.0] { + let corners: Vec = (0..3) + .map(|i| { + let a = (direction - 90.0).to_radians() + std::f64::consts::TAU * i as f64 / 3.0; + Point::new(200.0 + 70.0 * a.cos(), 300.0 + 70.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&wobbly(&corners, 24, 0.5)), Some(Recognized::Polygon { sides: 3, rotation: 0.0 | 180.0, .. }))); + } + } + #[test] fn square_fit_ignores_sampling_density_and_document_offset() { let mut stroke = wobbly(&[Point::new(0.0, 0.0), Point::new(100.0, 8.0), Point::new(94.0, 100.0), Point::new(10.0, 91.0)], 24, 4.0); for scale in [0.25, 1.0, 20.0] { let shifted: Vec = stroke.iter().map(|p| Point::new(1e9 + p.x * scale, -1e9 + p.y * scale)).collect(); - assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle(_))), "scale {scale}: {:?}", recognize(&shifted)); + assert!(matches!(recognize(&shifted), Some(Recognized::Rectangle { .. })), "scale {scale}: {:?}", recognize(&shifted)); } let dense: Vec = stroke.iter().enumerate().flat_map(|(i, p)| std::iter::repeat_n(*p, if i < 24 { 100 } else { 1 })).collect(); assert_eq!(recognize(&stroke), recognize(&dense)); stroke.extend(std::iter::repeat_n(*stroke.last().unwrap(), 10_000)); - assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle(_)))); + assert!(matches!(recognize(&stroke), Some(Recognized::Rectangle { .. }))); } #[test] diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 280f507e..885987f2 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -105,6 +105,12 @@ pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { if !n.visible { return; } + if n.shaper.is_some() { + for c in n.children().into_iter().flatten().skip(1) { + leaf_shapes(c, out); + } + return; + } match &n.kind { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), @@ -256,7 +262,7 @@ impl BuilderMap { } /// The regions just either side of the middle of `piece` (None: outside every region). - fn sides(&self, piece: &po::Shape) -> [Option; 2] { + pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; let n = sp.segment_count(); if n == 0 { diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 050c4bf0..1454d6ac 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -238,7 +238,7 @@ impl Tool for ShaperTool { vec![] } PointerKind::Drag => { - if !self.points.is_empty() && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) { + if !self.points.is_empty() && self.points.len() < 4095 && self.points.last().is_none_or(|l| l.distance(ev.pos) >= cx.tol(1.5)) { self.points.push(ev.pos); } vec![] @@ -249,36 +249,31 @@ impl Tool for ShaperTool { self.points.push(ev.pos); } let pts = std::mem::take(&mut self.points); - let r = |x: vectorcraft_geom::Rect| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height() }); + if pts.first().is_some_and(|first| pts.iter().all(|p| p.distance(*first) < cx.tol(3.0))) { + return vec![Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y]}))]; + } + let r = |x: vectorcraft_geom::Rect, rotation: f64| json!({ "x": x.x0, "y": x.y0, "width": x.width(), "height": x.height(), "rotation": rotation }); match recognize(&pts) { Some(Recognized::Line { a, b }) => vec![Action::Exec("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))], - Some(Recognized::Rectangle(x)) => vec![Action::Exec("shape.rectangle".into(), r(x))], - Some(Recognized::Ellipse(x)) => vec![Action::Exec("shape.ellipse".into(), r(x))], + Some(Recognized::Rectangle { rect, rotation }) => vec![Action::Exec("shape.rectangle".into(), r(rect, rotation))], + Some(Recognized::Ellipse { rect, rotation }) => vec![Action::Exec("shape.ellipse".into(), r(rect, rotation))], Some(Recognized::Polygon { center, radius, sides, rotation }) => { vec![Action::Exec( "shape.polygon".into(), json!({ "cx": center.x, "cy": center.y, "radius": radius, "sides": sides, "rotation": rotation }), )] } - Some(Recognized::Scribble(_)) => { - // Delete the topmost objects the scribble crosses. - let mut ids: Vec = vec![]; - for p in &pts { - if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, *p, cx.hit_options()) { - let id = h.top_object(cx.isolation).0; - if !ids.contains(&id) { - ids.push(id); - } - } - } - if ids.is_empty() { - return vec![]; - } - vec![Action::Exec("select.set".into(), json!({ "ids": ids })), Action::Exec("edit.clear".into(), json!({ "ids": ids }))] - } + Some(Recognized::Scribble(_)) => vec![Action::Exec( + "shaper.scribble".into(), + json!({"points": pts.iter().map(|p| [p.x, p.y]).collect::>(), "tolerance": cx.tol(4.0)}), + )], None => vec![], } } + PointerKind::DoubleClick => vec![ + Action::Exec("shaper.select".into(), json!({"point": [ev.pos.x, ev.pos.y], "source": true})), + Action::SwitchTool("selection".into()), + ], _ => vec![], } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c3cb04b9..b225e298 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -368,6 +368,7 @@ impl ToolContext<'_> { outline: self.outline, path_only: self.path_only, type_path_only: self.type_path_only, + scope: self.isolation, } } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 675a97d3..ee20a486 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -364,6 +364,13 @@ impl Tool for SelectionTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = pathtype::overlays(cx); + if let Some(source) = cx.isolation.and_then(|id| cx.doc.node(id)).filter(|n| n.name.as_deref() == Some(vectorcraft_doc::shaper::SOURCES)) { + for n in source.children().into_iter().flatten() { + if let Some(path) = n.path_data() { + o.push(Overlay::Path { path: path.to_bezpath(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: true }); + } + } + } match &self.state { State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), State::Corner(c) => o.extend(c.overlays(cx)), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 588924fa..e5bf2405 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1072,7 +1072,8 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { None => return true, } } - !matches!(n.kind, NodeKind::Envelope { .. }) && n.children().into_iter().flatten().any(|c| over(c, left)) + !matches!(n.kind, NodeKind::Envelope { .. }) + && n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())).any(|c| over(c, left)) } let mut left = OVERLAY_MAX_ANCHORS; nodes.into_iter().any(|n| over(n, &mut left)) @@ -1085,7 +1086,7 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { if matches!(n.kind, NodeKind::Envelope { .. }) { return; } - for c in n.children().into_iter().flatten() { + for c in n.children().into_iter().flatten().skip(usize::from(n.shaper.is_some())) { walk_drawn(c, f); } } @@ -1100,6 +1101,7 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c44ada02..ac27b7a5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -634,3 +634,7 @@ Clipboard Handling Práce se schránkou Appearance of Black Vzhled černé Devices Zařízení + Shaper Shaper + Expand Shaper Group Rozšířit skupinu Shaper + Release Shaper Group Uvolnit skupinu Shaper + Select Shaper Face Vybrat oblast Shaper diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e0bf847b..a94ff22b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3598,3 +3598,7 @@ axis Both Ambos Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento. Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo. VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo. + Shaper Shaper + Expand Shaper Group Expandir grupo Shaper + Release Shaper Group Soltar grupo Shaper + Select Shaper Face Seleccionar cara Shaper diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0d0abf3d..359f4e57 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3598,3 +3598,7 @@ axis Both Entrambi Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento. Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto. VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia. + Shaper Shaper + Expand Shaper Group Espandi gruppo Shaper + Release Shaper Group Rilascia gruppo Shaper + Select Shaper Face Seleziona faccia Shaper diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0576ef31..6dc70074 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3060,3 +3060,7 @@ theme Appearance 外観 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 ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。 + Shaper Shaper + Expand Shaper Group Shaper グループを拡張 + Release Shaper Group Shaper グループを解除 + Select Shaper Face Shaper の面を選択 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5d74b821..be334aea 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2648,3 +2648,7 @@ Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento. Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo. VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda. + Shaper Formador + Expand Shaper Group Expandir Grupo Formador + Release Shaper Group Liberar Grupo Formador + Select Shaper Face Selecionar Face do Formador diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1c6f1734..7fe6a122 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3315,3 +3315,6 @@ axis Both 两者 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 文档:一个锁定对象,文件更改时随之更新。 + Expand Shaper Group 扩展 Shaper 组 + Release Shaper Group 释放 Shaper 组 + Select Shaper Face 选择 Shaper 区域 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 55723ec8..8839c1fd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3090,3 +3090,7 @@ axis Both 兩者 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 文件:單一鎖定物件,檔案變更時隨之更新。 + Shaper Shaper + Expand Shaper Group 展開 Shaper 群組 + Release Shaper Group 釋放 Shaper 群組 + Select Shaper Face 選取 Shaper 區域 diff --git a/docs/shaper.md b/docs/shaper.md new file mode 100644 index 00000000..83e2751b --- /dev/null +++ b/docs/shaper.md @@ -0,0 +1,18 @@ +# Shaper + +Sketch a basic shape with the Shaper tool (Shift+N). Rectangles, squares and ellipses snap to 45° rotation steps; hexagons to 90°. Triangles point up or down; sideways sketches snap to one of those orientations. Lines keep the angle you draw, including while Shift is held. + +Scribble over overlapping paths to make an editable Shaper Group: + +- Start in a filled region and cross into other regions to merge the regions touched. The result uses the starting region’s fill. +- Scribble within a region to remove its fill and keep its stroke. Hidden overlap boundaries are part of the same visible face; visible dividing strokes create separate faces. +- End the scribble outside the artwork to remove the touched fills and exposed boundary strokes. Shared edges along surviving filled areas remain, closing their contours. Compatible stroke pieces join at the new corners, so the selected stroke’s join style applies there. +- Scribble over an unfilled stroke segment to remove that piece. A line passing through a shape is split at the intersections, so its protruding ends can be removed separately. + +Click once to select the group; click again to select its visible fill and change its color. Double-click with Shaper to enter construction editing and select an original shape. The Selection tool then moves or resizes it normally, and the visible composition updates. Escape leaves construction editing. The original live shapes and edit recipes survive native save/reopen. Outside construction editing, selection highlights and bounds show only the visible result. + +Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments. + +The construction-mode arrow widget and finer gesture heuristics remain to be implemented. + +Behavior reference: [public Shaper documentation](https://helpx.adobe.com/illustrator/using/building-new-shapes-using-shape.html). Rotation steps and independent fill/stroke cases follow the contributor’s supplied requirements. No reference images or product assets are included. From 37d4477362c6dab397b906fe2894fc67a40d3bc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:59:05 +0700 Subject: [PATCH 36/74] Fix #592: file dialogs on Linux no longer stop the window answering VectorCraft asks for a path where it needs one and goes on with it at once, so its file dialogs ran on the UI thread. Windows and macOS dialogs run the window's events meanwhile; on Linux nothing answered the Wayland compositor's pings while a dialog was open, and Hyprland offered to kill the window as not responding. - ui-egui `picks`: every file dialog (open, save, folder, place) goes through one place. With the host's `start_pick` (desktop Linux) the dialog shows on a thread of its own; what asked (the command, the dialog's OK, a panel's or dialog's button) gives up for now and runs again with the picked path when the dialog answers. Another dialog waits for the open one. - The desktop app passes its window as the dialogs' parent on Linux (the portal can then associate its window, "Unhandled parent window type" before) and Windows; macOS shows them as before. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/vectorcraft/src/main.rs | 88 +++++-- .../ui-egui/src/dialogs/export_for_screens.rs | 7 +- .../ui-egui/src/dialogs/flattener_presets.rs | 17 +- crates/ui-egui/src/dialogs/missing_links.rs | 2 +- crates/ui-egui/src/dialogs/mod.rs | 3 +- crates/ui-egui/src/dialogs/package.rs | 7 +- crates/ui-egui/src/dialogs/pdf_presets.rs | 17 +- .../src/dialogs/perspective_presets.rs | 17 +- crates/ui-egui/src/dialogs/print_presets.rs | 17 +- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.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/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/io.rs | 4 +- crates/ui-egui/src/lib.rs | 21 ++ crates/ui-egui/src/panels/asset_export.rs | 16 +- crates/ui-egui/src/panels/library_panel.rs | 2 +- crates/ui-egui/src/panels/links.rs | 21 +- crates/ui-egui/src/picks.rs | 237 ++++++++++++++++++ crates/ui-egui/src/place.rs | 8 +- crates/ui-egui/src/shortcut_editor.rs | 4 +- crates/ui-egui/src/tests_picks.rs | 113 +++++++++ 27 files changed, 536 insertions(+), 75 deletions(-) create mode 100644 crates/ui-egui/src/picks.rs create mode 100644 crates/ui-egui/src/tests_picks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6e7067c8..71cf20a0 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); 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), 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 549a3ac7..e6a3b9a1 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -34,6 +34,7 @@ mod window; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; use vectorcraft_ui_egui::graphics::GraphicsLoss; +use vectorcraft_ui_egui::picks::PickRequest; use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp}; struct App { @@ -185,9 +186,22 @@ fn save_prefs(app: &VectorcraftApp) { } } -/// A native file dialog showing `pick`'s file types, folder and suggested name. -fn file_dialog(pick: &FilePick) -> rfd::FileDialog { - let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts)); +/// The window file dialogs belong to. +type Parent = Option>; + +/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then +/// shows it over the window and keeps it in front; macOS shows them as before). +fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { + let d = match parent { + Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w), + _ => rfd::FileDialog::new(), + }; + let pick = match request { + PickRequest::Open(pick) | PickRequest::Save(pick) => pick, + PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)), + PickRequest::Folder => return d, + }; + let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts)); let d = match &pick.folder { Some(folder) => d.set_directory(folder), None => d, @@ -195,6 +209,28 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog { if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) } } +/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. +fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec { + let paths = match request { + PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), + PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), + PickRequest::Save(pick) => { + // The Templates folder may not exist yet. + if let Some(folder) = &pick.folder { + let _ = std::fs::create_dir_all(folder); + } + dialog.save_file().into_iter().collect() + } + PickRequest::Folder => dialog.pick_folder().into_iter().collect(), + }; + paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect() +} + +/// Show the dialog for `request` over `parent` now → the paths picked. +fn pick_now(request: PickRequest, parent: &Parent) -> Vec { + show_dialog(file_dialog(&request, parent), &request) +} + /// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere. fn reveal(path: &str) -> Result<(), String> { reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}")) @@ -230,25 +266,31 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) } -fn services() -> Services { +/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the +/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then +/// offers to kill the window as not responding (#592). +fn start_pick(request: PickRequest, parent: &Parent) -> Option>> { + let dialog = file_dialog(&request, parent); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(show_dialog(dialog, &request)); + }) + .ok()?; + Some(rx) +} + +fn services(parent: Parent) -> Services { + let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); Services { - pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))), - pick_open_multi: Some(Box::new(|| { - fileio::place_filters() - .fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })), - pick_save: Some(Box::new(|pick: &FilePick| { - // The Templates folder may not exist yet. - if let Some(folder) = &pick.folder { - let _ = std::fs::create_dir_all(folder); - } - file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string()) - })), + pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())), + pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))), + pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())), + // Windows and macOS dialogs run the window's events while they are open; Linux's don't. + start_pick: cfg!(all(unix, not(target_os = "macos"))) + .then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), // Background Save and Export write from a worker thread. @@ -266,7 +308,7 @@ fn services() -> Services { reveal: Some(Box::new(reveal)), // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), - pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))), + pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), // File → Print: the system's printers and print queue. print: Some(Box::new(printing::SystemPrint)), ..Default::default() @@ -397,7 +439,7 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services()); + let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned())); load_prefs(&mut app, saved); // Fit the window to its monitor, or put it back where it was (still hidden). if let Some(w) = cc.winit_window() { diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index f37d05c5..89435854 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -424,11 +424,8 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let pick = app.services.pick_folder.is_some(); ui.horizontal(|ui| { form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 }); - if pick - && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick()) - { - d.fields.insert("folder".into(), json!(f)); + if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() { + crate::picks::folder_field(app, d, "folder"); } }); form::check(ui, d, "openLocation", tl!("Open Location after Export")); diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index 909f3bf7..c50bedf3 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -145,10 +145,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } } } - if let Some(a) = act - && let Err(e) = run(app, d, a, &preset.name) - { - app.status(e); + if let Some(a) = act { + let r = if matches!(a, Action::Import) { + // Its file dialog, shown off the UI thread, imports into the dialog as it is then. + let current = preset.name.clone(); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t)) + } else { + run(app, d, a, &preset.name) + }; + if let Err(e) = r { + app.status(e); + } } false } @@ -181,7 +188,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> return Ok(()); } let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() }; - let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("flattener.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index c436e3be..2d7b3c7e 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -122,7 +122,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { p if !p.is_empty() => p, _ => { let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; - app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")? + crate::picks::open(app, &pick).ok_or("cancelled")? } }; out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?; diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 2d82a591..019f6eed 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -315,7 +315,8 @@ pub fn cancel(app: &mut VectorcraftApp) { /// Apply the open dialog (OK). pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; - (spec(&d.kind).confirm)(app, &d) + // 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)) } pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 2859a2eb..234141c9 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -70,11 +70,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if !downloads(app) { widgets::label_row(ui, tl!("Location:"), LABEL, |ui| { form::text(ui, d, "folder", 230.0); - if let Some(pick) = app.services.pick_folder.as_mut() - && ui.button(tl!("Choose…")).clicked() - && let Some(f) = pick() - { - d.fields.insert("folder".into(), json!(f)); + if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() { + crate::picks::folder_field(app, d, "folder"); } }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 0d63fdd1..d0c6afaf 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -102,10 +102,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { details(ui, &t, preset, builtin); }); }); - if let Some(a) = act - && let Err(e) = run(app, d, a, &preset.name) - { - app.status(e); + if let Some(a) = act { + let r = if matches!(a, Action::Import) { + // Its file dialog, shown off the UI thread, imports into the dialog as it is then. + let current = preset.name.clone(); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t)) + } else { + run(app, d, a, &preset.name) + }; + if let Err(e) = r { + app.status(e); + } } false } @@ -169,7 +176,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> return Ok(()); } let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() }; - let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("pdf.preset.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index be4d2109..3dd13855 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -150,10 +150,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); }); let name = preset.name.clone(); - if let Some(a) = act - && let Err(e) = run(app, d, a, &name) - { - app.status(e); + if let Some(a) = act { + let r = if matches!(a, Action::Import) { + // Its file dialog, shown off the UI thread, imports into the dialog as it is then. + let current = name.clone(); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t)) + } else { + run(app, d, a, &name) + }; + if let Err(e) = r { + app.status(e); + } } false } @@ -187,7 +194,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current return Ok(()); } let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; - let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("perspective.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index 1d1da489..36b95e4b 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -111,10 +111,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); }); - if let Some(a) = act - && let Err(e) = run(app, d, a, &name) - { - app.status(e); + if let Some(a) = act { + let r = if matches!(a, Action::Import) { + // Its file dialog, shown off the UI thread, imports into the dialog as it is then. + let current = name.clone(); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, ¤t)) + } else { + run(app, d, a, &name) + }; + if let Err(e) = r { + app.status(e); + } } false } @@ -149,7 +156,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current return Ok(()); } let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() }; - let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("print.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { select(d, first); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c44ada02..82c42bc8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -634,3 +634,4 @@ Clipboard Handling Práce se schránkou Appearance of Black Vzhled černé Devices Zařízení + A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4ceed41..6196c3d1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3598,3 +3598,4 @@ axis Both Ambos Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento. Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo. VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo. + A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index decd91fd..6cf4879c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3598,3 +3598,4 @@ axis Both Entrambi Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento. Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto. VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia. + A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b479ea62..f9102846 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3060,3 +3060,4 @@ theme Appearance 外観 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 ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。 + A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9500ba89..6753ee1d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2648,3 +2648,4 @@ Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento. Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo. VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda. + A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 53cafffa..ae4256a5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3596,3 +3596,4 @@ axis Both Оба 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 Открыто окно выбора файла: сначала сделайте выбор в нём diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ddc8134f..677ca8f4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3315,3 +3315,4 @@ axis Both 两者 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 文件对话框已打开:请先在其中选择 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a9d704ba..0312ff1a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3090,3 +3090,4 @@ axis Both 兩者 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 檔案對話方塊已開啟:請先在其中選擇 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cf7c08a6..375a271f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -72,7 +72,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O /// A path from the open dialog, or "cancelled". fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { - app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) + crate::picks::open(app, pick).ok_or_else(|| "cancelled".into()) } /// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the @@ -164,7 +164,7 @@ fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result>, /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. pub native_menu: Option, + /// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window + /// and the compositor finds it not answering): what asked runs again with the answer + /// ([`picks`]). Without it they are shown in line. + pub start_pick: Option, } /// Cached canvas raster. @@ -347,6 +354,8 @@ pub struct VectorcraftApp { /// The thread that checks the system clipboard for `system_paste` instead /// ([`Services::clipboard_probe`]). clipboard_probe: Option, + /// File dialogs shown off the UI thread and what runs again with their answers. + pub(crate) picks: picks::Picks, /// The look for fonts installed or removed while the app was in the background, running /// ([`Self::refresh_installed_fonts`]): whether they were. font_check: Option>, @@ -426,6 +435,7 @@ impl VectorcraftApp { system_paste: false, system_paste_at: f64::NEG_INFINITY, clipboard_probe: None, + picks: picks::Picks::default(), font_check: None, paste_chord: Default::default(), background: Default::default(), @@ -489,6 +499,16 @@ impl VectorcraftApp { /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { + // A file dialog it shows off the UI thread runs it again with the path picked. + if self.picks.is_entry_free() { + let entry = picks::Entry::Command(id.to_string(), params.clone()); + return picks::as_entry(self, move || entry, |app| app.run_now(id, params)); + } + self.run_now(id, params) + } + + /// [`Self::run`] it, inside what asks for file dialogs. + fn run_now(&mut self, id: &str, params: Value) -> Result { self.run_count = self.run_count.wrapping_add(1); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; @@ -790,6 +810,7 @@ impl VectorcraftApp { self.system_paste = wanted && self.system_clipboard_pasteable(); } self.poll_font_check(ctx); + picks::poll(self, ctx); background::poll(self); if !self.background.jobs.is_empty() { // Keep the status bar's progress moving and pick the result up when it arrives. diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 5a38619c..ced2d907 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -185,17 +185,23 @@ fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option, files_each: usize) { + // The folder picked off the UI thread exports them then. + crate::picks::button(app, move |app| export_now(app, ids.clone(), files_each)); +} + +fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { let mut p = json!({ "assets": ids }); if io::is_web(app) { p["zip"] = json!(ids.len() * files_each > 1); } else { let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default(); - let folder = match app.services.pick_folder.as_mut() { - Some(pick) => match pick() { + let folder = if crate::picks::can(app, &crate::picks::PickRequest::Folder) { + match crate::picks::folder(app) { Some(f) => f, None => return, - }, - None => match saved + } + } else { + match saved .get("folder") .and_then(Value::as_str) .filter(|f| !f.is_empty()) @@ -204,7 +210,7 @@ fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { { Some(f) => f, None => return app.status("choose a folder to export to".to_string()), - }, + } }; p["folder"] = json!(folder); p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index bd68193d..ca934825 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -154,7 +154,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) } path.or_else(|| { let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; - app.services.pick_open.as_mut().and_then(|f| f(&pick)) + crate::picks::open(app, &pick) }) .map(Some) .ok_or_else(|| "cancelled".into()) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index fef4221f..3250b16b 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -116,15 +116,23 @@ fn report(app: &mut VectorcraftApp, r: Result, done: impl FnOnce( /// Relink images `ids` to a picked file. pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { + crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +} + +fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; - let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return }; + let Some(path) = crate::picks::open(app, &pick) else { return }; let r = app.run("links.relink", json!({ "ids": ids, "path": path })); report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); } /// Relink images `ids` (none: every missing one) to the files of their names in a picked folder. fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec) { - let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return }; + crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone())); +} + +fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { + let Some(folder) = crate::picks::folder(app) else { return }; let ids = Some(ids).filter(|i| !i.is_empty()); let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); report(app, r, |v| { @@ -154,9 +162,12 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) { } return; } - let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return }; - let r = app.run("links.unembed", json!({ "id": id, "path": path })); - report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default())); + let name = name.to_string(); + crate::picks::button(app, move |app| { + let Some(path) = crate::picks::save(app, &crate::FilePick::named(&name)) else { return }; + let r = app.run("links.unembed", json!({ "id": id, "path": path })); + report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default())); + }); } /// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal` diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs new file mode 100644 index 00000000..463a47fc --- /dev/null +++ b/crates/ui-egui/src/picks.rs @@ -0,0 +1,237 @@ +//! File dialogs that leave the window running (#592). +//! +//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…) +//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it +//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing +//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the +//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now, +//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the +//! *entry*) runs again and gets the picked path at once. + +use std::rc::Rc; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use serde_json::Value; + +use crate::state::Dialog; +use crate::{FilePick, VectorcraftApp}; + +/// A file dialog to show. +#[derive(Clone, Debug, PartialEq)] +pub enum PickRequest { + /// An open dialog for one file. + Open(FilePick), + /// An open dialog for files to place. + OpenMany, + /// A save dialog. + Save(FilePick), + /// A folder picker. + Folder, +} + +/// The paths a dialog shown off the UI thread picked (none: cancelled). +pub type PickAnswer = Vec; + +/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and +/// the dialog is then shown in line. +pub type StartPick = Box Option>>; + +/// What runs again with the picked path. +#[derive(Clone)] +pub(crate) enum Entry { + /// A command (`app.run`). + Command(String, Value), + /// A dialog's OK (`dialogs::confirm`), on the dialog as it was. + Confirm(Box), + /// A panel's or a dialog's button. + Call(Rc), +} + +/// A dialog shown off the UI thread, waiting for its answer. +struct Waiting { + request: PickRequest, + answer: Receiver, + entry: Entry, +} + +/// The app's file dialogs ([`VectorcraftApp`]'s `picks`). +#[derive(Default)] +pub(crate) struct Picks { + /// What is running now and asks for a path, the outermost one. + entry: Option, + waiting: Option, + /// A picked answer for the entry running again: the dialog it asks for gets it. + answer: Option<(PickRequest, PickAnswer)>, +} + +impl Picks { + /// Nothing asking for file dialogs is running. + pub(crate) fn is_entry_free(&self) -> bool { + self.entry.is_none() + } +} + +/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it +/// answers. Inside another entry, that one runs again instead. +pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + if app.picks.entry.is_some() { + return f(app); + } + app.picks.entry = Some(entry()); + let r = f(app); + app.picks.entry = None; + r +} + +/// [`as_entry`] for a panel's or a dialog's button that `f` handles. +pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { + let f = Rc::new(f); + let again = f.clone(); + as_entry(app, move || Entry::Call(again), |app| f(app)); +} + +/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown +/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind. +pub(crate) fn in_dialog( + app: &mut VectorcraftApp, + d: &mut Dialog, + f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static, +) -> Result<(), String> { + let f = Rc::new(f); + let (again, kind) = (f.clone(), d.kind.clone()); + let entry = move || { + Entry::Call(Rc::new(move |app: &mut VectorcraftApp| { + let Some(mut d) = app.ui.dialog.take() else { return }; + let r = if d.kind == kind { again(app, &mut d) } else { Ok(()) }; + app.ui.dialog = Some(d); + if let Err(e) = r { + app.status(e); + } + })) + }; + as_entry(app, entry, |app| f(app, d)) +} + +/// A dialog body's folder button: the folder picked into field `key` of `d` ([`in_dialog`]). +pub(crate) fn folder_field(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) { + let r = in_dialog(app, d, move |app, d| { + if let Some(f) = folder(app) { + d.fields.insert(key.into(), serde_json::json!(f)); + } + Ok(()) + }); + // Picking a folder doesn't fail. + let _ = r; +} + +/// A path from an open dialog for `pick` (none: cancelled, or shown off the UI thread for now). +pub(crate) fn open(app: &mut VectorcraftApp, pick: &FilePick) -> Option { + ask(app, PickRequest::Open(pick.clone()))?.into_iter().next() +} + +/// A path from a save dialog for `pick`. +pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option { + ask(app, PickRequest::Save(pick.clone()))?.into_iter().next() +} + +/// A folder from the folder picker. +pub(crate) fn folder(app: &mut VectorcraftApp) -> Option { + ask(app, PickRequest::Folder)?.into_iter().next() +} + +/// Paths from the open dialog for files to place (none: cancelled). +pub(crate) fn open_many(app: &mut VectorcraftApp) -> Vec { + ask(app, PickRequest::OpenMany).unwrap_or_default() +} + +/// Whether the host shows `request`'s kind of dialog at all. +pub(crate) fn can(app: &VectorcraftApp, request: &PickRequest) -> bool { + let s = &app.services; + match request { + PickRequest::Open(_) => s.pick_open.is_some(), + PickRequest::OpenMany => s.pick_open_multi.is_some() || s.pick_open.is_some(), + PickRequest::Save(_) => s.pick_save.is_some(), + PickRequest::Folder => s.pick_folder.is_some(), + } +} + +/// The paths `request`'s dialog picked: the answer kept for it, else shown off the UI thread (no +/// paths for now), else shown in line. +fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option { + // One for another dialog is dropped: the entry took another way this time. + if let Some((asked, answer)) = app.picks.answer.take() + && asked == request + { + return Some(answer); + } + if !can(app, &request) { + return None; + } + if let (Some(entry), Some(start)) = (app.picks.entry.clone(), app.services.start_pick.as_mut()) { + if app.picks.waiting.is_some() { + app.status(tl!("A file dialog is open: choose there first")); + return None; + } + if let Some(answer) = start(request.clone()) { + app.picks.waiting = Some(Waiting { request, answer, entry }); + return None; + } + } + let s = &mut app.services; + let one = |p: Option| Some(p.into_iter().collect()); + match &request { + PickRequest::Open(pick) => one(s.pick_open.as_mut().and_then(|f| f(pick))), + PickRequest::Save(pick) => one(s.pick_save.as_mut().and_then(|f| f(pick))), + PickRequest::Folder => one(s.pick_folder.as_mut().and_then(|f| f())), + PickRequest::OpenMany => match (s.pick_open_multi.as_mut(), s.pick_open.as_mut()) { + (Some(many), _) => Some(many()), + (None, Some(f)) => one(f(&FilePick::default())), + (None, None) => None, + }, + } +} + +/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again +/// with them. While it is open, look again shortly. +pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(w) = &app.picks.waiting else { return }; + let answer = match w.answer.try_recv() { + Ok(answer) => answer, + Err(TryRecvError::Empty) => { + // What asked gave up for now: no "cancelled" while the dialog is open. + if app.ui.status == "cancelled" { + app.ui.status.clear(); + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + } + // The dialog's thread is gone: as cancelled. + Err(TryRecvError::Disconnected) => vec![], + }; + let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return }; + if answer.is_empty() { + return; + } + app.picks.answer = Some((request, answer)); + rerun(app, entry); + // An entry that took another way this time leaves the answer unused. + app.picks.answer = None; +} + +/// Run `entry` again. +fn rerun(app: &mut VectorcraftApp, entry: Entry) { + let r = match entry { + Entry::Command(id, params) => app.run(&id, params).map(|_| ()), + Entry::Confirm(d) => { + app.ui.dialog = Some(*d); + crate::dialogs::confirm(app).map(|_| ()) + } + Entry::Call(f) => { + f(app); + Ok(()) + } + }; + if let Err(e) = r { + app.status(e); + } +} diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index c3a6f75f..56cb9222 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -135,10 +135,10 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> { f(); return Ok(()); } - let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) { - (Some(f), _) => f(), - (None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(), - (None, None) => vec![], + let paths = if app.services.pick_open_multi.is_some() { + crate::picks::open_many(app) + } else { + crate::picks::open(app, &crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect() }; if paths.is_empty() { return Err("cancelled".into()); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index d327b916..6e9cdd72 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -458,7 +458,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); - let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json")))); + let path = s("path").or_else(|| crate::picks::save(app, &crate::FilePick::named("VectorCraft Shortcuts.json"))); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), @@ -477,7 +477,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())), (None, _) => Err("cancelled".into()), diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs new file mode 100644 index 00000000..28d123ec --- /dev/null +++ b/crates/ui-egui/src/tests_picks.rs @@ -0,0 +1,113 @@ +//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::{Sender, channel}; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::picks::{self, PickRequest}; +use crate::state::Dialog; +use crate::{FilePick, Services, VectorcraftApp}; + +const SVG: &[u8] = br#""#; + +/// A dialog asked for, and where its answer goes. +type Asked = (PickRequest, Sender>); + +/// The dialogs showing. +#[derive(Clone, Default)] +struct Shown(Rc>>); + +impl Shown { + fn requests(&self) -> Vec { + self.0.borrow().iter().map(|(r, _)| r.clone()).collect() + } + /// The last dialog shown answers `paths`. + fn answer(&self, paths: &[&str]) { + let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing"); + tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap(); + } +} + +/// A desktop app whose dialogs show off the UI thread (none in line), reading [`SVG`] from any path +/// and writing files to `written`. +fn app() -> (VectorcraftApp, Shown, Rc>>) { + let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![]))); + let (s, w) = (shown.clone(), written.clone()); + let in_line = |_: &FilePick| -> Option { panic!("no dialog on the UI thread") }; + let services = Services { + pick_open: Some(Box::new(in_line)), + pick_save: Some(Box::new(in_line)), + pick_folder: Some(Box::new(|| panic!("no dialog on the UI thread"))), + read: Some(Box::new(|_: &str| Ok(SVG.to_vec()))), + write: Some(Box::new(move |path: &str, _: &[u8]| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })), + start_pick: Some(Box::new(move |request: PickRequest| { + let (tx, rx) = channel(); + s.0.borrow_mut().push((request, tx)); + Some(rx) + })), + ..Default::default() + }; + (VectorcraftApp::new(Session::new(), services), shown, written) +} + +fn poll(app: &mut VectorcraftApp) { + picks::poll(app, &egui::Context::default()); +} + +#[test] +fn file_open_opens_what_its_dialog_picks_when_it_answers() { + let (mut app, shown, _) = app(); + // The dialog shows; File › Open gives up for now, without a "cancelled". + assert!(app.run("file.open", json!({})).is_err()); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + poll(&mut app); + assert!(app.session.documents().is_empty() && app.ui.status.is_empty(), "{}", app.ui.status); + // Another dialog waits for this one. + assert!(app.run("file.open", json!({})).is_err()); + assert_eq!(shown.requests().len(), 1); + assert!(app.ui.status.contains("file dialog is open"), "{}", app.ui.status); + shown.answer(&["/art/a.svg"]); + poll(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().and_then(|d| d.path.clone()).as_deref(), Some("/art/a.svg")); + // Cancelled: nothing happens, and dialogs show again. + assert!(app.run("file.open", json!({})).is_err()); + shown.answer(&[]); + poll(&mut app); + assert_eq!(app.session.documents().len(), 1); + assert!(app.run("file.open", json!({})).is_err()); + assert_eq!(shown.requests().len(), 1); +} + +#[test] +fn save_as_goes_on_with_the_path_picked() { + let (mut app, shown, written) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + assert!(app.run("file.saveAs", json!({})).is_err()); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); + shown.answer(&["/art/poster.svg"]); + poll(&mut app); + // SVG Options asks next, and its OK writes the file there. + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(*written.borrow(), ["/art/poster.svg"]); +} + +#[test] +fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() { + let (mut app, shown, _) = app(); + let mut d = Dialog::new("exportForScreens", json!({"folder": ""})); + app.ui.dialog = Some(d.clone()); + picks::folder_field(&mut app, &mut d, "folder"); + assert_eq!(shown.requests(), [PickRequest::Folder]); + shown.answer(&["/out"]); + poll(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out")); +} From 0df82313342b80d2db97dbdeb3ef8ed1d31a6798 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:00:01 +0700 Subject: [PATCH 37/74] Tests: format round trips find their objects by content, not by count #606 adds a second area type (Shrink Text to Fit) to the rich fixture: the vertical-alignment test now picks the centred area type by its text and strips verticalAlign from every area for its old-file case, and the composer test checks the Single-line text and the rest at the default. Co-Authored-By: Claude Opus 5.5 --- crates/format/tests/native_v3.rs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index 5919897e..dd6f0a83 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -53,8 +53,10 @@ fn area_type_vertical_alignment_round_trips() { let area = |d: &Document| { let mut found = None; d.walk(|n| { + // The fixture's centred area type, by its text (the fixture has other area type). if let NodeKind::Text(t) = &n.kind && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) + && t.runs.iter().map(|r| r.text.as_str()).collect::() == "Centred area type" { found = Some(t.area.vertical_align); } @@ -63,7 +65,20 @@ fn area_type_vertical_alignment_round_trips() { }; assert_eq!(area(&back), vectorcraft_doc::VerticalAlign::Center); // Files from before the option open top-aligned. - let old = text.replacen("\"verticalAlign\":\"center\",", "", 1).replacen(",\"verticalAlign\":\"center\"", "", 1); + // (Every area type the fixture has writes its options, so the key goes from all of them.) + fn strip(v: &mut Value) { + match v { + Value::Object(m) => { + m.remove("verticalAlign"); + m.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut v = json_of(text.as_bytes()); + strip(&mut v); + let old = serde_json::to_string(&v).unwrap(); assert!(!old.contains("verticalAlign")); assert_eq!(area(&load(old.as_bytes()).unwrap()), vectorcraft_doc::VerticalAlign::Top); } @@ -149,11 +164,12 @@ fn text_composer_round_trips() { }); v }; - // The point type set to Single-line, then the area type left at the default. - assert_eq!(composers(&d), [Composer::SingleLine, Composer::EveryLine]); + // The point type set to Single-line comes first; the fixture's other type keeps the default. + let single_then_default = |c: &[Composer]| c.len() > 1 && c[0] == Composer::SingleLine && c[1..].iter().all(|c| *c == Composer::EveryLine); + assert!(single_then_default(&composers(&d)), "{:?}", composers(&d)); let bytes = save(&d, false); assert!(String::from_utf8_lossy(&bytes).contains("\"composer\":\"singleLine\"")); - assert_eq!(composers(&load(&bytes).unwrap()), [Composer::SingleLine, Composer::EveryLine]); + assert_eq!(composers(&load(&bytes).unwrap()), composers(&d)); // Files without the key (older files, Every-line text) read as Every-line. fn strip(v: &mut Value) { match v { @@ -168,7 +184,7 @@ fn text_composer_round_trips() { let mut v = json_of(&bytes); strip(&mut v); let d2 = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(composers(&d2), [Composer::EveryLine, Composer::EveryLine]); + assert!(composers(&d2).iter().all(|c| *c == Composer::EveryLine), "{:?}", composers(&d2)); assert!(!String::from_utf8_lossy(&save(&d2, false)).contains("composer")); } From dc6f138a72124ce3b64a04e0aef8964eb128e31d Mon Sep 17 00:00:00 2001 From: David Whiting Date: Thu, 8 Oct 2026 23:03:34 +0100 Subject: [PATCH 38/74] M2.9: translate Shaper commands in Russian --- crates/ui-egui/src/i18n/ru.tsv | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a40acd24..f136c502 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3596,3 +3596,7 @@ axis Both Оба 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: один заблокированный объект, обновляемый при изменении файла. + Shaper Shaper + Expand Shaper Group Разобрать группу Shaper + Release Shaper Group Освободить группу Shaper + Select Shaper Face Выбрать область Shaper From f7761bf5bc5df728dfee6641455073e5017f33fe Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:55:41 -0700 Subject: [PATCH 39/74] M7: per-paragraph paragraph attributes Each paragraph has its own alignment, indents, spacing, hyphenation, tab stops and paragraph style (TextObject.paras; para stays paragraph 0's, so old files and readers work). Layout, editing (split/merge), threads, the Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/ tabs/paraStyle.apply, SVG export/import and the fuzzers follow. --- ROADMAP.md | 6 +- crates/doc/src/text.rs | 156 ++++++++++++++ crates/engine/src/cmd/appearance.rs | 5 +- crates/engine/src/cmd/clipboard/resources.rs | 8 +- crates/engine/src/cmd/panelcmds.rs | 81 ++++---- crates/engine/src/cmd/tabs.rs | 28 ++- crates/engine/src/cmd/textedit.rs | 9 + crates/engine/src/cmd/textstyles.rs | 44 ++-- crates/engine/src/cmd/threads.rs | 46 +++-- crates/engine/src/cmd/typecmd.rs | 72 ++++++- crates/engine/src/cmd/typemenu.rs | 16 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_paragraphs.rs | 207 +++++++++++++++++++ crates/engine/tests/command_sweep.rs | 9 + crates/svg/src/css.rs | 4 + crates/svg/src/export.rs | 68 ++++-- crates/svg/src/import/text.rs | 57 +++-- crates/svg/tests/text_layout.rs | 34 +++ crates/testkit/src/fixtures.rs | 9 + crates/text/src/layout.rs | 23 ++- crates/text/src/tests.rs | 61 ++++++ crates/text/src/tests_bidi.rs | 24 +++ crates/text/src/thread.rs | 101 ++++++++- crates/tools/src/text.rs | 16 +- crates/tools/src/text_tests.rs | 11 +- crates/ui-egui/src/panels/character.rs | 7 +- crates/ui-egui/src/panels/paragraph.rs | 7 +- crates/ui-egui/src/panels/tabs.rs | 18 +- crates/ui-egui/src/panels/text_styles.rs | 5 +- docs/mcp.md | 26 +++ 30 files changed, 991 insertions(+), 169 deletions(-) create mode 100644 crates/engine/src/tests_paragraphs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7a24fc4a..266579b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,7 +139,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), 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. 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:** 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. 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). - **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -176,7 +176,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -211,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| 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, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 | +| 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 62e9436d..fa19cb5a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -812,6 +812,11 @@ fn reverse_from_end(path: &mut PathData) { } /// A text object. `runs` split into paragraphs at `\n`. +/// +/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every +/// paragraph's). Invariant (kept by [`TextObject::normalize_paras`]): `paras` is either empty — +/// every paragraph uses `para` — or holds one style per paragraph with `paras[0] == para` and at +/// least two different styles. Readers that only know `para` see the first paragraph's style. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextObject { /// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents. @@ -823,6 +828,9 @@ pub struct TextObject { pub runs: Vec, #[serde(default)] pub para: ParaStyle, + /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paras: Vec, /// Area Type Options (area type only). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub area: AreaOptions, @@ -857,6 +865,7 @@ impl TextObject { xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], para: ParaStyle::default(), + paras: Vec::new(), area: AreaOptions::default(), path_effect: PathEffect::default(), path_align: PathAlign::default(), @@ -869,6 +878,103 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// Number of paragraphs (`\n`-separated; empty text has one). + pub fn paragraph_count(&self) -> usize { + 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() + } + /// Attributes of paragraph `i` (the last paragraph's past the end). + pub fn para_at(&self, i: usize) -> &ParaStyle { + self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) + } + /// Every paragraph's attributes, one per paragraph. + pub fn paragraph_styles(&self) -> Vec { + (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() + } + /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its + /// last entry, a long one is cut). + pub fn set_paragraph_styles(&mut self, styles: Vec) { + if let Some(first) = styles.first() { + self.para = first.clone(); + } + self.paras = styles; + self.normalize_paras(); + } + /// The same attributes for every paragraph. + pub fn set_all_paras(&mut self, style: ParaStyle) { + self.para = style; + self.paras.clear(); + } + /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the + /// last), `paras[0] == para`, and empty when every paragraph is alike. + pub fn normalize_paras(&mut self) { + if self.paras.is_empty() { + return; + } + let n = self.paragraph_count(); + let last = self.paras.last().cloned().unwrap_or_default(); + self.paras.resize(n, last); + if let Some(first) = self.paras.first_mut() { + first.clone_from(&self.para); + } + if self.paras.iter().all(|p| *p == self.para) { + self.paras.clear(); + } + } + /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret + /// touches its paragraph). + pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { + let text = self.plain_text(); + let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); + let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); + index(a)..index(b) + 1 + } + /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). + pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { + match range { + None => { + f(&mut self.para); + for p in &mut self.paras { + f(p); + } + } + Some(r) => { + let mut v = self.paragraph_styles(); + for p in v.iter_mut().take(r.end).skip(r.start) { + f(p); + } + self.set_paragraph_styles(v); + } + } + self.normalize_paras(); + } + /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. + pub fn para_styles_mut(&mut self) -> impl Iterator { + std::iter::once(&mut self.para).chain(self.paras.iter_mut()) + } + /// Every stored paragraph style (`para` and the per-paragraph ones). + pub fn para_styles(&self) -> impl Iterator { + std::iter::once(&self.para).chain(self.paras.iter()) + } + /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by + /// `insert` (call before changing the runs). The paragraph the range starts in keeps its + /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that + /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. + pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { + if self.paras.is_empty() { + return; + } + let r = self.paragraphs_in(start, end); + let v = self.paragraph_styles(); + let Some(keep) = v.get(r.start).cloned() else { return }; + let added = insert.bytes().filter(|&b| b == b'\n').count(); + let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); + out.extend(std::iter::repeat_n(keep, added)); + out.extend(v.iter().skip(r.end).cloned()); + self.paras = out; + if let Some(first) = self.paras.first() { + self.para = first.clone(); + } + } pub fn first_style(&self) -> CharStyle { self.runs.first().map(|r| r.style.clone()).unwrap_or_default() } @@ -996,6 +1102,56 @@ mod tests { assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); } + fn justified(j: Justify) -> ParaStyle { + ParaStyle { justify: j, ..ParaStyle::default() } + } + + #[test] + fn paragraph_styles_split_merge_and_normalize() { + let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); + assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); + assert_eq!(t.paragraphs_in(0, 0), 0..1); + assert_eq!(t.paragraphs_in(4, 4), 1..2); + assert_eq!(t.paragraphs_in(2, 9), 0..3); + assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); + t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); + let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // Return inside "two": the new paragraph continues its style. + t.splice_paras(5, 5, "\n"); + t.runs[0].text.insert(5, '\n'); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); + // Deleting the break between "one" and "t": the first paragraph's style wins. + t.splice_paras(3, 4, ""); + t.runs[0].text.remove(3); + assert_eq!(t.plain_text(), "onet\nwo\nthree"); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); + // An edit that skips the splice is repaired by normalizing (the last style continues). + t.runs[0].text.push_str("\nfour"); + t.normalize_paras(); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); + // `para` is paragraph 0's; all alike collapses to `para` alone. + t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); + assert_eq!(t.para.justify, Justify::Right); + t.edit_paras(None, |p| p.justify = Justify::Right); + assert!(t.paras.is_empty() && t.para.justify == Justify::Right); + // A short list repeats its last entry, a long one is cut. + t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); + assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); + t.set_paragraph_styles(vec![justified(Justify::Center); 9]); + assert!(t.paras.is_empty() && t.para.justify == Justify::Center); + // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. + t.edit_paras(Some(3..4), |p| p.space_before = 4.0); + let json = serde_json::to_value(&t).unwrap(); + assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); + let mut old = json.clone(); + old.as_object_mut().unwrap().remove("paras"); + let back: TextObject = serde_json::from_value(old).unwrap(); + assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); + let back: TextObject = serde_json::from_value(json).unwrap(); + assert_eq!(back.para_at(3).space_before, 4.0); + } + /// 120 × 40 area type at (40, 40), its text drawn at twice its size. fn area_type() -> TextObject { let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index cfb42df3..a83cdde4 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -803,9 +803,10 @@ impl Picked { run_attrs(&mut r.style, &placed, ap); } if let Some((_, para)) = text.filter(|_| a.paragraph) { - t.para = para.clone(); + // The source's first paragraph's attributes, on every paragraph. + t.set_all_paras(para.clone()); } - if text.is_some() && a.character { + if text.is_some() && (a.character || a.paragraph) { super::typecmd::refresh_bounds(t); } return; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index e966c644..baf5b62d 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -122,7 +122,7 @@ fn node_refs(n: &Node, f: Visit) { f(Ref::Name(Res::CharStyle, s)); } } - if let Some(s) = &t.para.style_name { + for s in t.para_styles().filter_map(|pa| pa.style_name.as_ref()) { f(Ref::Name(Res::ParaStyle, s)); } } @@ -440,8 +440,10 @@ impl Relink { self.name(Res::CharStyle, s); } } - if let Some(s) = &mut t.para.style_name { - self.name(Res::ParaStyle, s); + for pa in t.para_styles_mut() { + if let Some(s) = &mut pa.style_name { + self.name(Res::ParaStyle, s); + } } } NodeKind::Image(im) => self.name(Res::Image, &mut im.key), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 02d7d62e..852d6e7a 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default)}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -180,6 +180,9 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector // ---------- text ---------- +/// Largest indent or paragraph spacing (points; Illustrator's limit). +const MAX_PARA: f64 = 1296.0; + fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; let ids: Vec = { @@ -268,11 +271,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")), }), }; + let range = super::typecmd::TextRange::parse(p, C)?; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - for r in &mut t.runs { - let st = &mut r.style; + super::typecmd::style_chars(t, range, |st| { if let Some(k) = kerning { st.kerning = k; } @@ -306,41 +309,43 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } - } - let para = &mut t.para; - if let Some(v) = num("leftIndent") { - para.left_indent = v; - } - if let Some(v) = num("rightIndent") { - para.right_indent = v; - } - if let Some(v) = num("firstLineIndent") { - para.first_line_indent = v; - } - if let Some(v) = num("spaceBefore") { - para.space_before = v; - } - if let Some(v) = num("spaceAfter") { - para.space_after = v; - } - if let Some(v) = flag("hyphenate") { - para.hyphenate = v; - } - if let Some(v) = mojikumi { - para.mojikumi = v; - } - if let Some(v) = direction { - para.direction = v; - } - if let Some(v) = leading_model { - para.leading_model = v; - } - if let Some(v) = burasagari { - para.burasagari = v; - } - if let Some(v) = composer { - para.composer = v; - } + }); + let span = super::typecmd::para_span(range, t); + t.edit_paras(span, |para| { + if let Some(v) = num("leftIndent") { + para.left_indent = v.clamp(-MAX_PARA, MAX_PARA); + } + if let Some(v) = num("rightIndent") { + para.right_indent = v.clamp(-MAX_PARA, MAX_PARA); + } + if let Some(v) = num("firstLineIndent") { + para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA); + } + if let Some(v) = num("spaceBefore") { + para.space_before = v.clamp(-MAX_PARA, MAX_PARA); + } + if let Some(v) = num("spaceAfter") { + para.space_after = v.clamp(-MAX_PARA, MAX_PARA); + } + if let Some(v) = flag("hyphenate") { + para.hyphenate = v; + } + if let Some(v) = mojikumi { + para.mojikumi = v; + } + if let Some(v) = direction { + para.direction = v; + } + if let Some(v) = leading_model { + para.leading_model = v; + } + if let Some(v) = burasagari { + para.burasagari = v; + } + if let Some(v) = composer { + para.composer = v; + } + }); super::typecmd::refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 3c732fc6..7d56b27d 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{NodeKind, TabAlign, TabStop}; -use super::typecmd::{refresh_bounds, text_targets}; +use super::typecmd::{TextRange, para_span, refresh_bounds, text_targets}; use super::*; pub fn specs() -> Vec { @@ -13,12 +13,25 @@ pub fn specs() -> Vec { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches)", has_selection, set ), - cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} → {stops} of the first selected text object", has_selection, get), - cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")), + cmd!( + query "text.tabs.get", + "Tab Stops", + [], + None, + "{ids?, start?: byte} → {stops} of the first selected text object (its paragraph at `start`; default the first)", + has_selection, + get + ), + cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?, start?: byte, end?: byte}", has_selection, |s, p| apply( + s, + p, + vec![], + "Clear All Tabs" + )), ] } @@ -54,11 +67,13 @@ fn set(s: &mut Session, p: &Value) -> Result { fn apply(s: &mut Session, p: &Value, stops: Vec, label: &str) -> Result { let ids = text_targets(s, p, "text.tabs.set")?; + let range = TextRange::parse(p, "text.tabs.set")?; let n = stops.len(); s.edit(label, |d, _| { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { - t.para.tabs = stops.clone(); + let span = para_span(range, t); + t.edit_paras(span, |pa| pa.tabs = stops.clone()); refresh_bounds(t); } } @@ -69,9 +84,10 @@ fn apply(s: &mut Session, p: &Value, stops: Vec, label: &str) -> Result fn get(s: &mut Session, p: &Value) -> Result { let ids = text_targets(s, p, "text.tabs.get")?; + let range = TextRange::parse(p, "text.tabs.get")?; let st = s.doc()?; let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()), + Some(NodeKind::Text(t)) => Some(t.para_at(para_span(range, t).map_or(0, |r| r.start)).tabs.clone()), _ => None, }); Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) })) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a454bd25..bd95964f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -135,6 +135,13 @@ fn edit_range(s: &mut Session, p: &Value) -> Result { let insert = str_param(p, "insert").unwrap_or("").to_string(); let caret = s.edit("Typing", |d, _| { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; + // Paragraph styles follow the edit: a split paragraph's new one continues its style, a + // merge keeps the first paragraph's. + let inserted: String = match &styled { + Some(r) => r.iter().map(|r| r.text.as_str()).collect(), + None => insert.clone(), + }; + t.splice_paras(a, b, &inserted); let caret = match &styled { Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r), None => edit::replace_range(&mut t.runs, a, b, &insert), @@ -463,6 +470,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], para: super::create::new_type_para(), + paras: Vec::new(), area: Default::default(), path_effect: Default::default(), path_align: Default::default(), @@ -600,6 +608,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { xf: Affine::IDENTITY, runs: head.clone(), para: Default::default(), + paras: Vec::new(), area: Default::default(), path_effect: Default::default(), path_align: Default::default(), diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index f8e58db8..fd9f3491 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -66,7 +66,7 @@ macro_rules! style_cmds { concat!($p, ".apply"), concat!("Apply ", $what, " Style"), &[], - "{name, clearOverrides?, ids?|id + start?/end? (a Type tool range)} → {count}", + "{name, clearOverrides?, ids?|id + start?/end? (a Type tool range: character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}", |s, p| apply(s, p, $kind), true, ), @@ -179,7 +179,7 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb } } -/// How many runs (char) or text objects (para) use `name`. +/// How many runs (char) or paragraphs (para) use `name`. fn uses(d: &Document, kind: Kind, name: &str) -> usize { let target = (name != kind.normal()).then_some(name); let mut n = 0; @@ -187,7 +187,7 @@ fn uses(d: &Document, kind: Kind, name: &str) -> usize { if let NodeKind::Text(t) = &node.kind { n += match kind { Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(), - Kind::Para => usize::from(t.para.style_name.as_deref() == target), + Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(), }; } }); @@ -210,9 +210,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map { - if t.para.style_name == target { - t.para = restyle(&t.para, old, new); - changed = true; + for pa in t.para_styles_mut() { + if pa.style_name == target { + *pa = restyle(pa, old, new); + changed = true; + } } } } @@ -231,12 +233,11 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()), }; let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) }; + let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)); Ok(Some(match kind { - Kind::Char => { - let at = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; - to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)) - } - Kind::Para => to_attrs(&t.para), + Kind::Char => to_attrs(&vectorcraft_text::edit::style_at(&t.runs, at)), + // The paragraph at `start`. + Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)), })) } @@ -329,8 +330,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { let normal = kind.attrs(d, kind.normal()).unwrap_or_default(); let clear = bool_or(p, "clearOverrides", false); let style_name = (name != kind.normal()).then(|| name.clone()); - // A Type tool range (character styles only) or whole text objects. - let range = id_param(p, "id").filter(|_| kind == Kind::Char && (p.get("start").is_some() || p.get("end").is_some())); + // A Type tool range or whole text objects. + let text_range = super::typecmd::TextRange::parse(p, &c)?; + let range = id_param(p, "id").filter(|_| text_range.is_some()); let ids = match range.or_else(|| id_param(p, "id")) { Some(id) => vec![id], None => text_targets(s, p, &c)?, @@ -376,7 +378,17 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { return Err(e); } } - Kind::Para => t.para = para_fn(&t.para)?, + Kind::Para => { + let mut v = t.paragraph_styles(); + let span = match (range, text_range) { + (Some(_), Some(r)) => r.paras(t), + _ => 0..v.len(), + }; + for st in v.iter_mut().take(span.end).skip(span.start) { + *st = para_fn(st)?; + } + t.set_paragraph_styles(v); + } } refresh_bounds(t); } @@ -447,7 +459,7 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { }; match kind { Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), - Kind::Para => fix(&mut t.para.style_name), + Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)), } } Ok(()) @@ -472,7 +484,7 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { }; match kind { Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)), - Kind::Para => fix(&mut t.para.style_name), + Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)), } } Ok(()) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 03227f2b..a6c83626 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -75,14 +75,24 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { }); // The story: live frames' runs, plus the text of frames deleted by this edit. let mut story: Vec = vec![]; + let mut sources: Vec = vec![]; + let mut later_wins = vec![]; let mut live = vec![]; for id in &thread { match (text_of(doc, *id), text_of(before, *id)) { - (Some(t), _) => { + (Some(t), old) => { story.extend(t.runs.iter().cloned()); + // A frame whose first paragraph was just restyled gives the paragraph it + // shares with the frame before it that style. + later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0))); + sources.push(t.clone()); live.push(*id); } - (None, Some(old)) => story.extend(old.runs.iter().cloned()), + (None, Some(old)) => { + story.extend(old.runs.iter().cloned()); + later_wins.push(false); + sources.push(old.clone()); + } (None, None) => {} } } @@ -92,6 +102,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } continue; } + let source_refs: Vec<&TextObject> = sources.iter().collect(); + let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins); vectorcraft_text::edit::normalize(&mut story); if live.len() >= 2 { // Threaded frames keep their size: Auto Size is off for them (as in Illustrator). @@ -105,13 +117,18 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { } let frames: Vec = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect(); let refs: Vec<&TextObject> = frames.iter().collect(); - let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story); - for (id, runs) in live.iter().zip(parts) { - if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) - && t.runs != runs - { - t.runs = runs; - refresh_bounds(t); + let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s); + for (id, part) in live.iter().zip(parts) { + if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) { + let mut next = (**t).clone(); + next.runs = part.runs; + next.set_paragraph_styles(part.paras); + if next.runs != t.runs || next.para != t.para || next.paras != t.paras { + t.runs = next.runs; + t.para = next.para; + t.paras = next.paras; + refresh_bounds(t); + } } } if live.len() >= 2 { @@ -193,15 +210,17 @@ fn release(s: &mut Session, _: &Value) -> Result { continue; } let mut story: Vec = vec![]; - for id in &thread { - if let Some(t) = text_of(d, *id) { - story.extend(t.runs.iter().cloned()); - } + let sources: Vec = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect(); + for t in &sources { + story.extend(t.runs.iter().cloned()); } + let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::>(), &[]); let style = story.first().map(|r| r.style.clone()).unwrap_or_default(); + let first_para = paras.first().cloned().unwrap_or_default(); for id in &out { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { t.runs = vec![TextRun { text: String::new(), style: style.clone() }]; + t.set_all_paras(first_para.clone()); refresh_bounds(t); } } @@ -210,6 +229,7 @@ fn release(s: &mut Session, _: &Value) -> Result { { vectorcraft_text::edit::normalize(&mut story); t.runs = story; + t.set_paragraph_styles(paras); } let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0); d.text_threads[i] = keep; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6b7daebf..6062781a 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", has_doc, set_style ), @@ -102,6 +102,8 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { /// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo /// step as the edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { + // Edits that changed the paragraph count keep one paragraph style per paragraph. + t.normalize_paras(); auto_height(t); let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); @@ -255,6 +257,60 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result, + end: Option, +} + +impl TextRange { + /// `start`/`end` of `p`, if either is given (non-negative integers). + pub(crate) fn parse(p: &Value, cmd: &str) -> Result> { + let get = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset"))) + } + } + }; + let (start, end) = (get("start")?, get("end")?); + Ok((start.is_some() || end.is_some()).then_some(Self { start, end })) + } + /// The byte range in `t`, clamped and ordered. + pub(crate) fn bytes(&self, t: &TextObject) -> (usize, usize) { + let len = vectorcraft_text::edit::runs_len(&t.runs); + let a = self.start.unwrap_or(0).min(len); + let b = self.end.unwrap_or(len).min(len); + (a.min(b), a.max(b)) + } + /// The paragraphs of `t` the range touches. + pub(crate) fn paras(&self, t: &TextObject) -> std::ops::Range { + let (a, b) = self.bytes(t); + t.paragraphs_in(a, b) + } +} + +/// Paragraphs of `t` an optional range touches (None: every paragraph). +pub(crate) fn para_span(range: Option, t: &TextObject) -> Option> { + range.map(|r| r.paras(t)) +} + +/// Apply `f` to the character styles of `range` of `t` (None: every run). +pub(crate) fn style_chars(t: &mut TextObject, range: Option, mut f: impl FnMut(&mut vectorcraft_doc::CharStyle)) { + match range { + None => t.runs.iter_mut().for_each(|r| f(&mut r.style)), + Some(r) => { + let (a, b) = r.bytes(t); + if a < b { + vectorcraft_text::edit::style_range(&mut t.runs, a, b, f); + } + } + } +} + fn set_text(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string(); let ids = text_targets(s, p, "text.setText")?; @@ -262,6 +318,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let style = t.first_style(); + // Every paragraph of the new text takes the first paragraph's attributes. + t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text); t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }]; refresh_bounds(t); } @@ -319,14 +377,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "nothing to change")); } + let range = TextRange::parse(p, C)?; let ids = text_targets(s, p, C)?; let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some()); s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let before = protect.then(|| t.runs.clone()); - for r in &mut t.runs { - let st = &mut r.style; + style_chars(t, range, |st| { if let Some(f) = &font { st.font_family = f.clone(); } @@ -348,12 +406,13 @@ fn set_style(s: &mut Session, p: &Value) -> Result { if let Some(f) = &features { st.features = f.clone(); } - } + }); if let Some(before) = before { super::textedit::protect_missing_glyphs(&before, &mut t.runs); } if let Some(j) = justify { - t.para.justify = j; + let span = para_span(range, t); + t.edit_paras(span, |pa| pa.justify = j); } refresh_bounds(t); } @@ -788,7 +847,8 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe); let cell = lay.frames.first()?; let line = lay.lines.first()?; - let avail = cell.width() - 2.0 * t.area.inset - t.para.left_indent - t.para.right_indent; + let para = t.para_at(0); + let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent; Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count())) } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 8d936bce..4db829e0 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -272,7 +272,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); let slack = size * 0.5; // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. - let (sl, sr) = match t.para.justify { + let (sl, sr) = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), Justify::Right | Justify::JustifyRight => (slack, 0.0), Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), @@ -315,20 +315,22 @@ fn to_point(s: &mut Session, p: &Value) -> Result { breaks.sort_unstable(); breaks.dedup(); for b in breaks.into_iter().rev() { + // Each wrapped line becomes a paragraph with its paragraph's attributes. if plain.as_bytes().get(b - 1) == Some(&b' ') { if let Some((ri, bi)) = locate(&t.runs, b - 1) { + t.splice_paras(b - 1, b, "\n"); t.runs[ri].text.replace_range(bi..bi + 1, "\n"); } } else if let Some((ri, bi)) = locate(&t.runs, b) { + t.splice_paras(b, b, "\n"); t.runs[ri].text.insert(bi, '\n'); } } // The point origin sits where the alignment anchors the first line. - let (x0, x1) = lay - .lines - .first() - .map_or((fb.x0, fb.x1), |l| (l.avail.0 - t.para.left_indent - t.para.first_line_indent, l.avail.1 + t.para.right_indent)); - let ox = match t.para.justify { + let p0 = t.para_at(0); + let (x0, x1) = + lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent)); + let ox = match p0.justify { Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5, Justify::Right | Justify::JustifyRight => x1, Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, @@ -646,7 +648,7 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) { if let NodeKind::Text(t) = &mut n.kind { let text = t.plain_text(); t.runs = vec![TextRun { text, style: CharStyle::default() }]; - t.para = Default::default(); + t.set_all_paras(Default::default()); refresh_bounds(t); } if let Some(ch) = n.children_mut() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4b8668d2..b7d1a89b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1431,6 +1431,8 @@ mod tests_paintproxy; #[cfg(test)] mod tests_panelcmds; #[cfg(test)] +mod tests_paragraphs; +#[cfg(test)] mod tests_pathops; #[cfg(test)] mod tests_pathtype; diff --git a/crates/engine/src/tests_paragraphs.rs b/crates/engine/src/tests_paragraphs.rs new file mode 100644 index 00000000..114c3946 --- /dev/null +++ b/crates/engine/src/tests_paragraphs.rs @@ -0,0 +1,207 @@ +//! Paragraph attributes per paragraph: ranged Paragraph panel commands, paragraph styles on one +//! paragraph, splitting and merging paragraphs while editing, undo and threaded text. + +use serde_json::json; +use vectorcraft_doc::{Justify, NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn area(s: &mut Session, text: &str) -> u64 { + s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 300}})).unwrap()["id"].as_u64().unwrap() +} + +fn text(s: &Session, id: u64) -> TextObject { + match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn justs(s: &Session, id: u64) -> Vec { + text(s, id).paragraph_styles().iter().map(|p| p.justify).collect() +} + +#[test] +fn ranged_paragraph_commands_touch_only_their_paragraphs() { + let mut s = session(); + let id = area(&mut s, "one\ntwo\nthree"); + // A caret in "two" (byte 5): only the second paragraph. + s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]); + // A selection from "one" into "two": both. + s.execute("text.setFormat", &json!({"ids": [id], "spaceBefore": 9, "leftIndent": 4, "start": 1, "end": 5})).unwrap(); + let t = text(&s, id); + let sb: Vec = t.paragraph_styles().iter().map(|p| p.space_before).collect(); + assert_eq!(sb, [9.0, 9.0, 0.0]); + assert_eq!(t.para.left_indent, 4.0, "paragraph 0 is `para`"); + // Character attributes of setFormat follow the range too. + s.execute("text.setFormat", &json!({"ids": [id], "underline": true, "start": 4, "end": 7})).unwrap(); + let t = text(&s, id); + assert!(t.runs.iter().any(|r| r.text == "two" && r.style.underline)); + assert!(t.runs.iter().any(|r| r.text.starts_with("one") && !r.style.underline)); + // The layout sees each paragraph's alignment. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines[1].x0 > lay.lines[0].x0 + 20.0, "the centred line sits right of the left one"); + // No range: every paragraph (as before). + s.execute("text.setStyle", &json!({"id": id, "justify": "right"})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Right; 3]); + // Undo steps back through the ranged edits. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]); + // Tabs per paragraph; a query reads the paragraph at `start`. + s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 40}], "start": 9, "end": 9})).unwrap(); + assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id], "start": 9})).unwrap()["stops"].as_array().unwrap().len(), 1); + assert_eq!(s.execute("text.tabs.get", &json!({"ids": [id]})).unwrap()["stops"], json!([])); + // Junk ranges are errors, not panics. + assert!(s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": -3})).is_err()); + assert!(s.execute("text.setStyle", &json!({"id": id, "justify": "left", "end": "x"})).is_err()); + s.execute("text.setFormat", &json!({"ids": [id], "spaceAfter": 1, "start": 1_000_000, "end": u64::MAX})).unwrap(); + assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph"); +} + +#[test] +fn editing_splits_and_merges_paragraph_styles() { + let mut s = session(); + let id = area(&mut s, "one\ntwo\nthree"); + s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap(); + // A break inside "two": both halves keep its style. + s.execute("text.editRange", &json!({"id": id, "start": 5, "end": 5, "insert": "\n"})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nt\nwo\nthree"); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]); + // Deleting the break after "one": the first paragraph's style wins. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 4})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "onet\nwo\nthree"); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Auto]); + // Pasting styled runs with breaks in "wo": the new paragraphs continue it. + let runs = json!([{"text": "A\nB\n", "style": text(&s, id).runs[0].style}]); + s.execute("text.editRange", &json!({"id": id, "start": 6, "end": 6, "runs": runs})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Center, Justify::Auto]); + // Undo restores the styles with the text. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Center, Justify::Center, Justify::Auto]); + // Replacing all the text: every paragraph takes the first one's attributes. + s.execute("text.setText", &json!({"ids": [id], "text": "x\ny"})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Auto, Justify::Auto]); + assert!(text(&s, id).paras.is_empty()); +} + +#[test] +fn return_in_the_type_tool_continues_the_paragraph_it_splits() { + let mut s = session(); + let v = ViewInfo::default(); + let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "one\ntwo", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "justify": "right", "start": 0, "end": 0})).unwrap(); + s.execute("text.setStyle", &json!({"id": id, "justify": "center", "start": 5, "end": 5})).unwrap(); + s.select_tool("type", v).unwrap(); + // Click into the first line (right-aligned: it ends at the origin), then End and Return. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + let l0 = &lay.lines[0]; + let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + s.tool_key(ToolKey::End, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Enter, Mods::default(), v).unwrap(); + s.tool_text("new", v).unwrap(); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(text(&s, id).plain_text(), "one\nnew\ntwo"); + assert_eq!(justs(&s, id), [Justify::Right, Justify::Right, Justify::Center]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]); +} + +#[test] +fn paragraph_style_applies_to_one_paragraph() { + let mut s = session(); + let id = area(&mut s, "Title\nBody text"); + // The second paragraph keeps new type's attributes, whatever they are. + let body = text(&s, id).para_at(1).clone(); + s.execute("paraStyle.new", &json!({"name": "Head", "attrs": {"justify": "Center", "space_after": 8.0}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Head", "id": id, "start": 2, "end": 2})).unwrap(); + let t = text(&s, id); + assert_eq!(t.para_at(0).style_name.as_deref(), Some("Head")); + assert_eq!((t.para_at(0).justify, t.para_at(0).space_after), (Justify::Center, 8.0)); + assert_eq!(t.para_at(1), &body, "new type's"); + let list = s.execute("paraStyle.list", &json!({})).unwrap(); + assert_eq!(list["styles"][1]["uses"], 1, "one paragraph uses it"); + assert_eq!(list["styles"][0]["uses"], 1, "the other is Normal"); + // Redefining updates that paragraph only; renaming follows it. + s.execute("paraStyle.setAttrs", &json!({"name": "Head", "attrs": {"justify": "Right"}})).unwrap(); + assert_eq!(justs(&s, id), [Justify::Right, Justify::Auto]); + s.execute("paraStyle.rename", &json!({"name": "Head", "to": "Heading"})).unwrap(); + assert_eq!(text(&s, id).para_at(0).style_name.as_deref(), Some("Heading")); + // New from the selection reads the caret's paragraph. + s.execute("paraStyle.new", &json!({"name": "From body", "id": id, "start": 8})).unwrap(); + let defs = &s.doc().unwrap().doc.para_styles; + assert_eq!(defs.iter().find(|d| d.name == "From body").unwrap().attrs["justify"], "Auto"); +} + +#[test] +fn threaded_frames_keep_each_paragraphs_style() { + let mut s = session(); + let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(3) + "\nSecond paragraph.\nThird."; + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let joined = |s: &Session| format!("{}{}", text(s, a).plain_text(), text(s, b).plain_text()); + assert_eq!(joined(&s), story); + // Centre the second paragraph (in frame b) and right-align the third. + let tb = text(&s, b).plain_text(); + let second = tb.find("Second").unwrap(); + s.execute("text.setStyle", &json!({"id": b, "justify": "center", "start": second, "end": second})).unwrap(); + let third = text(&s, b).plain_text().find("Third").unwrap(); + s.execute("text.setStyle", &json!({"id": b, "justify": "right", "start": third, "end": third})).unwrap(); + assert_eq!(justs(&s, b), [Justify::Auto, Justify::Center, Justify::Right]); + // Typing in the first frame re-flows the story: the styles move with their paragraphs. + s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "More words at the start of the story. "})).unwrap(); + let (ja, jb) = (justs(&s, a), justs(&s, b)); + assert_eq!(ja, [Justify::Auto]); + assert_eq!(jb, [Justify::Auto, Justify::Center, Justify::Right]); + // A break in the first frame makes a new paragraph there; the later ones keep their styles. + s.execute("text.editRange", &json!({"id": a, "start": 4, "end": 4, "insert": "\n"})).unwrap(); + let all: Vec = [justs(&s, a), justs(&s, b)].concat(); + assert_eq!(all.iter().filter(|j| **j == Justify::Center).count(), 1); + assert_eq!(all.last(), Some(&Justify::Right)); + // Restyling the paragraph that continues into frame b (its first) sticks after the re-flow. + s.execute("text.setStyle", &json!({"id": b, "justify": "justifyAll", "start": 0, "end": 0})).unwrap(); + assert_eq!(justs(&s, b)[0], Justify::JustifyAll); + assert_eq!(*justs(&s, a).last().unwrap(), Justify::JustifyAll, "the paragraph's part in frame a follows"); +} + +/// Upstream's paragraph attributes (direction, mojikumi, leading model) follow a range like the +/// others: only the touched paragraphs change, and the layout reads each paragraph's own. +#[test] +fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() { + use vectorcraft_doc::{LeadingModel, Mojikumi, ParaDirection}; + let mut s = session(); + let id = area(&mut s, "Hello.\nHello."); + // A caret in the second paragraph (byte 9). + s.execute( + "text.setFormat", + &json!({"ids": [id], "direction": "rightToLeft", "leadingModel": "emBoxTop", "mojikumi": "none", "start": 9, "end": 9}), + ) + .unwrap(); + let t = text(&s, id); + let (p0, p1) = (t.para_at(0).clone(), t.para_at(1).clone()); + assert_eq!((p0.direction, p0.leading_model, p0.mojikumi), (None, LeadingModel::RomanBaseline, Mojikumi::LineEndHalf)); + assert_eq!((p1.direction, p1.leading_model, p1.mojikumi), (Some(ParaDirection::RightToLeft), LeadingModel::EmBoxTop, Mojikumi::None)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(!lay.lines[0].rtl && lay.lines[1].rtl, "each paragraph's direction"); + // Return in the right-to-left paragraph continues it. + s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nX"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.para_at(2).direction, Some(ParaDirection::RightToLeft)); + // No range: every paragraph. + s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap(); + assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none())); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index bdb85e8c..03729b9a 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -274,6 +274,15 @@ fn structured_junk() { ("text.create", json!({"x": 0, "y": 0, "text": "\u{0}\u{FFFF}\u{1F600}", "size": -1})), ("text.create", json!({"x": 0, "y": 0, "text": "a", "size": 1e308, "area": {"width": -1, "height": 0}})), ("text.setStyle", json!({"size": 0, "leading": -1, "tracking": 1e308})), + ("text.setStyle", json!({"justify": "center", "start": u64::MAX, "end": 0})), + ("text.setStyle", json!({"justify": "right", "start": -1, "end": "x"})), + ("text.setFormat", json!({"spaceBefore": 1e308, "leftIndent": -1e308, "start": 3, "end": u64::MAX})), + ("text.setFormat", json!({"hyphenate": true, "underline": true, "start": 1.5, "end": null})), + ("text.setFormat", json!({"ids": [u64::MAX, 2], "firstLineIndent": 5, "start": 0, "end": 0})), + ("text.tabs.set", json!({"stops": [{"position": 10}], "start": u64::MAX, "end": u64::MAX})), + ("text.tabs.get", json!({"start": -5})), + ("paraStyle.apply", json!({"name": "[Normal Paragraph Style]", "id": 2, "start": u64::MAX, "end": 1})), + ("paraStyle.new", json!({"id": 2, "start": u64::MAX})), ("document.setUnits", json!({"units": "Parsecs"})), ("document.open", json!({"name": "x.svg", "dataBase64": "!!!"})), ("document.open", json!({"name": "x.svg", "dataBase64": "PHN2Zz4="})), diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 0589f323..4b7ea764 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -549,6 +549,10 @@ impl Rules<'_> { if let Some(l) = st.leading { art.props.push(("line-height", self.len(l))); } + // One element: the first paragraph's alignment. + if (1..t.paragraph_count()).any(|i| t.para_at(i).justify != t.para.justify) { + art.unsupported("paragraphs aligned differently"); + } let align = match t.para.justify { Justify::Auto | Justify::Left => None, Justify::Center => Some("center"), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 15422ed4..9528f627 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1683,18 +1683,27 @@ impl Writer<'_> { } self.note_fonts(t, &lay); let lines = text_lines(t, &lay, self.opts.fewer_tspans); - // A tab starts a new chunk at its stop: only lines without tabs can be anchored. - let anchor = match t.para.justify { - Justify::Center => Some(("middle", 0.5)), - Justify::Right => Some(("end", 1.0)), - _ => None, - } - .filter(|_| !lines.iter().flatten().any(|s| s.brk)); + // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its + // stop: lines with tabs are never anchored. + let anchors: Vec> = lines + .iter() + .map(|l| { + match t.para_at(l.para).justify { + Justify::Center => Some(("middle", 0.5)), + Justify::Right => Some(("end", 1.0)), + _ => None, + } + .filter(|_| !l.segs.iter().any(|s| s.brk)) + }) + .collect(); + // Every line anchored alike (the text's only paragraph style, typically): the + // carries the anchor. Otherwise each anchored line is a carrying its own. + let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); // The element's user space is text space. let space = (lay.bounds, Affine::IDENTITY); - let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() }; + let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() }; let mut props = base.props.clone(); - if let Some((a, _)) = anchor { + if let Some((a, _)) = uniform { props.push(("text-anchor", a.into())); } props.extend(css::transparency(n)); @@ -1702,7 +1711,7 @@ impl Writer<'_> { let a = self.attrs(&props); let m = self.xf * t.xf; let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - let starts: Vec> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect(); + let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); // The `` carries the first line's position too, so readers that place text by its // own x/y start where the first line does. let at = match starts.iter().flatten().next() { @@ -1710,9 +1719,17 @@ impl Writer<'_> { None => String::new(), }; let mut s = format!(""); - for (line, start) in lines.iter().zip(starts) { - if let Some(start) = start { - self.text_line(&mut s, t, line, start, &base); + for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { + let Some(start) = start else { continue }; + match anchor.filter(|_| uniform.is_none()) { + // A line anchored on its own: one positioned with the anchor around it. + Some((a, _)) => { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + let inner = TextBase { anchored: true, ..base.clone() }; + self.text_line(&mut s, t, &line.segs, start, &inner, false); + s.push_str(""); + } + None => self.text_line(&mut s, t, &line.segs, start, &base, true), } } s.push_str(""); @@ -1723,12 +1740,13 @@ impl Writer<'_> { /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with /// the style changes nested in it, positioned again only after tabs and justified word spaces. /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. - fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) { - let fewer = self.opts.fewer_tspans; + /// `positioned`: false when the caller already positioned the line (an outer ``). + fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { + let fewer = self.opts.fewer_tspans && positioned; if fewer { s.push_str(&format!("", self.num(start.0), self.num(start.1))); } - let mut place = !fewer; + let mut place = !fewer && positioned; let mut first = true; // The baseline shift the current text position carries. let mut shift = 0.0; @@ -1746,7 +1764,7 @@ impl Writer<'_> { shift = st.baseline_shift; } // After a tab or a justified word space the next segment is placed. - place = seg.brk || (!fewer && !base.anchored); + place = seg.brk || (!fewer && !base.anchored && positioned); if (st.h_scale - st.v_scale).abs() > 1e-9 { attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance))); } @@ -1848,7 +1866,7 @@ impl Writer<'_> { let space = (lay.bounds, self.xf * t.xf); let base = self.char_props(&t.first_style(), space); let mut props = base.clone(); - match t.para.justify { + match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), _ => {} @@ -1917,6 +1935,7 @@ fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { } /// What the lines of one `` share. +#[derive(Clone)] struct TextBase { /// The `` element's character properties, which each `` differs from. props: Props, @@ -1926,6 +1945,12 @@ struct TextBase { anchored: bool, } +/// One laid-out line: its paragraph (index) and its segments. +struct Line { + para: usize, + segs: Vec, +} + /// Characters of one style on one line, written as one ``. struct Segment { /// Index into `TextObject::runs`. @@ -1944,11 +1969,12 @@ struct Segment { /// on justified lines, after each word space (unless `fewer`). The text comes from the clusters /// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all /// caps are upper case. -fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec> { +fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); - let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); let mut lines = Vec::with_capacity(lay.lines.len()); for line in &lay.lines { + let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); + let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); let mut segs: Vec = Vec::new(); let mut cluster = None; let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; @@ -1983,7 +2009,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } segs.retain(|s| !s.text.is_empty()); if !segs.is_empty() { - lines.push(segs); + lines.push(Line { para, segs }); } } lines diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index d766d878..d36217e2 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -30,7 +30,7 @@ use std::str::FromStr; use usvg::roxmltree; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, StrokeLayer, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, ParaStyle, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; use vectorcraft_text::{FontDb, TextLayout}; @@ -556,11 +556,12 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor = match self.css.prop(cs.chars[0].1, "text-anchor").as_deref() { + let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() { Some("middle") => 0.5, Some("end") => 1.0, _ => 0.0, }; + let anchor = anchor_of(0); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { Some(p) => Some((tp, p)), None => { @@ -571,6 +572,7 @@ impl Ctx { let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; + let mut para_styles: Vec = vec![]; if let Some((tp, bp)) = path { // Along the path: dx is extra advance, dy shifts off the path. let mut off = 0.0; @@ -610,18 +612,38 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); + // Lines anchored differently (paragraphs aligned differently): each line's paragraph + // takes its alignment, and indents that put it where it was. + if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) { + for (k, &(i, ax)) in starts.iter().enumerate() { + let a = anchor_of(i); + let d = finite(ax - left); + let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() }; + match pa.justify { + Justify::Center if d >= 0.0 => pa.left_indent = 2.0 * d, + Justify::Center => pa.right_indent = -2.0 * d, + Justify::Right => pa.right_indent = -d, + _ => pa.left_indent = d, + } + let count = if k == 0 { 1 } else { breaks.get(k - 1).map_or(1, |b| b.count) }; + para_styles.extend(std::iter::repeat_n(pa, count)); + } + } } let (runs, servers) = assemble(&cells, &breaks).0; obj.runs = runs; + if !para_styles.is_empty() { + obj.set_paragraph_styles(para_styles); + } // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first - // strong character: pinned when that would read as right to left. + // strong character: pinned (on every paragraph) when that would read as right to left. if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { - obj.para.direction = Some(ParaDirection::RightToLeft); + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { - obj.para.direction = Some(ParaDirection::LeftToRight); + obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); } // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); @@ -708,8 +730,18 @@ fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } +/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end). +fn justify_of(anchor: f64) -> Justify { + match anchor { + a if a >= 1.0 => Justify::Right, + a if a > 0.0 => Justify::Center, + _ => Justify::Left, + } +} + /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the -/// justification and the x of the first line's left (or anchor) edge. +/// justification, the x of the first line's left (or anchor) edge and, when no character is +/// placed on its own, each line's first character and anchor x. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -720,7 +752,7 @@ fn lines( anchor: f64, x0: f64, breaks: &mut Vec, -) -> (Justify, f64) { +) -> (Justify, f64, Option>) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -758,12 +790,7 @@ fn lines( } } if targets.is_empty() { - let justify = match anchor { - a if a >= 1.0 => Justify::Right, - a if a > 0.0 => Justify::Center, - _ => Justify::Left, - }; - return (justify, x0); + return (justify_of(anchor), x0, Some(lines)); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. // SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each @@ -819,7 +846,7 @@ fn lines( break; } } - (Justify::Left, left) + (Justify::Left, left, None) } /// A left-aligned point text of `runs` at the origin (for measuring). diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index b3dd00c2..ab709943 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -200,3 +200,37 @@ fn a_viewer_draws_the_text_where_the_canvas_does() { } } } + +#[test] +fn paragraphs_aligned_differently_keep_their_alignment() { + let mut t = TextObject::point(Point::new(20.0, 20.0), "Left words here\nCentred line\nRight line", style(14.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 160.0)) }; + let para = |j| vectorcraft_doc::ParaStyle { justify: j, ..Default::default() }; + t.set_paragraph_styles(vec![para(Justify::Left), para(Justify::Center), para(Justify::Right)]); + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + for fewer_tspans in [false, true] { + let svg = export(&d, &ExportOptions { fewer_tspans, ..Default::default() }); + // Each anchored line carries its own anchor (the has none). + assert!(svg.contains("text-anchor=\"middle\"") && svg.contains("text-anchor=\"end\""), "{svg}"); + assert!(!svg.contains(" = text.paragraph_styles().iter().map(|p| p.justify).collect(); + assert_eq!(js, [Justify::Left, Justify::Center, Justify::Right], "{svg}"); + assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.01); + } +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index d02b944a..446ebb2b 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -129,6 +129,15 @@ pub fn rich_session() -> Session { json!({"x": 300, "y": 330, "size": 14, "text": "Area type shrinks its text to fit the frame.", "area": {"width": 150, "height": 40, "fit": "shrinkText", "fitMinPercent": 40}}), )); + // Area type whose paragraphs differ (alignment, spacing, tabs). + let para = id_of(&exec( + &mut s, + "text.create", + json!({"x": 20, "y": 360, "text": "Heading\nBody one\tx\nBody two", "size": 9, "area": {"width": 160, "height": 36}}), + )); + exec(&mut s, "text.setStyle", json!({"id": para, "justify": "center", "start": 0, "end": 0})); + exec(&mut s, "text.setFormat", json!({"ids": [para], "spaceBefore": 3, "firstLineIndent": 6, "start": 9, "end": 9})); + exec(&mut s, "text.tabs.set", json!({"ids": [para], "stops": [{"position": 80, "align": "right"}], "start": 9, "end": 9})); exec(&mut s, "layer.new", json!({"name": "Top"})); let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0); let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 82f2f68b..83bd52f8 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -250,7 +250,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout _ => QUARTER_TURN, }; match &t.kind { - TextKind::Point => flow(&mut cx, ¶s, &t.para, None), + TextKind::Point => flow(&mut cx, ¶s, t, None), TextKind::Area { frame } => { let to_lines = line_xf.inverse(); let wrap: Vec = if vertical { @@ -260,9 +260,9 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout }; let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap); cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect(); - flow(&mut cx, ¶s, &t.para, Some(®ions)); + flow(&mut cx, ¶s, t, Some(®ions)); if opts.vertical_align != VerticalAlign::Top { - align_vertically(&mut cx, ¶s, &t.para, &mut regions); + align_vertically(&mut cx, ¶s, t, &mut regions); } } TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), @@ -526,7 +526,7 @@ fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec, paras: &[Range], para: &ParaStyle, regions: &mut [Region]) { +fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: &mut [Region]) { let align = cx.opts.vertical_align; let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()); if plain { @@ -596,7 +596,7 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, } let before = laid_out(&cx.out); let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false)); - flow(cx, paras, para, Some(regions)); + flow(cx, paras, t, Some(regions)); if laid_out(&cx.out) < before { // Text no longer fits (lines got narrower, or flow around a wrap object): undo the // pass and try a smaller step. @@ -1291,7 +1291,9 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt compose(sg, &width, &start_credit, &cands, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, ¶ms(loose))) } -fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { +/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes +/// (alignment, indents, spacing, direction, mojikumi, leading model…). +fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Option<&[Region]>) { let mut pen = Pen { regions, ri: 0, @@ -1300,10 +1302,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![], - model: para.leading_model, + model: t.para_at(0).leading_model, next_top: None, }; 'paras: for (pi, pr) in paras.iter().enumerate() { + let para = t.para_at(pi); + // The leading model is a paragraph attribute: this paragraph's lines (and the space to + // its first line) follow its own. + pen.model = para.leading_model; let text = cx.text; let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); let rtl = is_rtl(bidi.as_ref()); @@ -1568,7 +1574,8 @@ fn path_steps(ap: &ArcPath, glyphs: &[SGlyph], from: f64, spacing: f64) -> Vec, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; - let para = &t.para; + // Type on a path is one line: the first paragraph's attributes align it. + let para = t.para_at(0); let mut sg = Vec::new(); // The first paragraph's direction aligns the line (Auto) and sets the caret's. let mut rtl = None; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b670fcb9..c67c6259 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -209,6 +209,27 @@ fn indents_and_paragraph_spacing() { assert!((l.lines[1].baseline - l.lines[0].baseline - 18.0).abs() < 1e-9); } +#[test] +fn each_paragraph_has_its_own_attributes() { + let frame = Rect::new(0.0, 0.0, 200.0, 400.0); + let mut t = area("left\ncentred\nright", style(10.0), frame, Justify::Left); + let centred = vectorcraft_doc::ParaStyle { justify: Justify::Center, space_before: 20.0, ..Default::default() }; + let right = vectorcraft_doc::ParaStyle { justify: Justify::Right, left_indent: 7.0, ..Default::default() }; + t.set_paragraph_styles(vec![t.para.clone(), centred, right]); + let l = layout(db(), &t); + assert_eq!(l.lines.len(), 3); + assert!(l.lines[0].x0.abs() < 1e-9, "the first paragraph stays left"); + let mid = (l.lines[1].x0 + l.lines[1].x1) / 2.0; + assert!((mid - 100.0).abs() < 1e-6, "the second is centred: {mid}"); + assert!((l.lines[2].x1 - 200.0).abs() < 1e-6, "the third is right-aligned"); + // Space before applies to the second paragraph only (leading 12 + 20). + assert!((l.lines[1].baseline - l.lines[0].baseline - 32.0).abs() < 1e-9); + assert!((l.lines[2].baseline - l.lines[1].baseline - 12.0).abs() < 1e-9); + // Paragraph 0's style is `para` (what readers that predate per-paragraph styles see). + assert_eq!(t.paras.len(), 3); + assert_eq!(t.para_at(0), &t.para); +} + #[test] fn non_rect_frame_narrows_lines() { // Triangle pointing up: lines get wider towards the bottom. @@ -558,6 +579,46 @@ fn em_box_top_leading_hangs_lines_from_the_line_above() { ); } +/// The leading model is a paragraph attribute: in "大 / 小 / 大 / 小" (40 pt over 20 pt) with +/// only the second paragraph top-to-top, its line hangs from the big line's em box (the big +/// line's leading, below it) while the fourth, baseline to baseline, takes its own leading above +/// it; in area type a top-to-top first paragraph puts its em box on the frame's top. +#[test] +fn leading_model_is_per_paragraph() { + use vectorcraft_doc::{LeadingModel, ParaStyle, TextRun}; + let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) }; + let model = |m: LeadingModel| ParaStyle { leading_model: m, ..ParaStyle::default() }; + let top = |size: f64| 0.88 * size; + let mut t = point("", st(40.0)); + t.runs = vec![ + TextRun { text: "大\n".into(), style: st(40.0) }, + TextRun { text: "小\n".into(), style: st(20.0) }, + TextRun { text: "大\n".into(), style: st(40.0) }, + TextRun { text: "小".into(), style: st(20.0) }, + ]; + t.set_paragraph_styles(vec![ + model(LeadingModel::RomanBaseline), + model(LeadingModel::EmBoxTop), + model(LeadingModel::RomanBaseline), + model(LeadingModel::RomanBaseline), + ]); + assert_eq!(t.paras.len(), 4); + let l = layout(db(), &t); + let gap = |i: usize| l.lines[i].baseline - l.lines[i - 1].baseline; + let em = -top(40.0) + 60.0 + top(20.0); + assert!((gap(1) - em).abs() < 0.01, "top to top: {} vs {em}", gap(1)); + assert!((gap(3) - 30.0).abs() < 0.01, "baseline to baseline: {}", gap(3)); + // Area type: only a top-to-top first paragraph moves the first line up to the frame's top. + let mut a = area("一\n二", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + a.set_paragraph_styles(vec![model(LeadingModel::RomanBaseline), model(LeadingModel::EmBoxTop)]); + let roman_first = layout(db(), &a).lines[0].baseline; + a.set_paragraph_styles(vec![model(LeadingModel::EmBoxTop), model(LeadingModel::RomanBaseline)]); + let em_first = layout(db(), &a); + assert!((em_first.lines[0].baseline - top(20.0)).abs() < 0.01, "{}", em_first.lines[0].baseline); + assert!((roman_first - em_first.lines[0].baseline).abs() > 0.5); + assert!((em_first.lines[1].baseline - em_first.lines[0].baseline - 30.0).abs() < 0.01); +} + /// Character Alignment: a 20 pt character next to a 40 pt one lines its em box top, centre or /// bottom up with the big one's (the em box from 0.12 em below the baseline to 0.88 em above it, /// in fonts without vertical metrics), or stays on the baseline; in vertical type the same across diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 0f088423..616d6e2d 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -312,3 +312,27 @@ fn visual_text_comes_back_in_logical_order() { assert_eq!(shown, visual); } } + +/// Paragraph Direction is a paragraph attribute: in one text object a right-to-left paragraph +/// reads and aligns (Auto) right to left while the next, left to right, starts on the left. +#[test] +fn paragraph_direction_is_per_paragraph() { + use vectorcraft_doc::{Justify, ParaDirection, ParaStyle}; + let s = "Hello.\nHello."; + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + let dir = |direction| ParaStyle { justify: Justify::Auto, direction, ..ParaStyle::default() }; + t.set_paragraph_styles(vec![dir(Some(ParaDirection::RightToLeft)), dir(Some(ParaDirection::LeftToRight))]); + assert_eq!(t.paras.len(), 2, "stored per paragraph"); + let l = layout(FontDb::global(), &t); + assert_eq!(l.lines.len(), 2); + let shown = |line: &crate::LineInfo| -> String { + let mut gs: Vec<_> = l.glyphs.iter().filter(|g| g.byte >= line.start && g.byte < line.end).collect(); + gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + gs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).filter(|c| *c != '\n').collect() + }; + assert!(l.lines[0].rtl && !l.lines[1].rtl); + assert_eq!(shown(&l.lines[0]), ".Hello", "the first paragraph runs right to left"); + assert_eq!(shown(&l.lines[1]), "Hello.", "the second runs left to right"); + 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); +} diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 0652d4d4..cbde2985 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -4,7 +4,7 @@ //! testing need nothing special; after an edit the engine joins the slices and re-distributes them //! with [`distribute`]. -use vectorcraft_doc::{TextObject, TextRun}; +use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; use crate::edit::{normalize, runs_len, slice_runs, style_at}; use crate::{FontDb, layout}; @@ -16,6 +16,36 @@ pub fn story(frames: &[&TextObject]) -> Vec { runs } +/// The story's paragraph styles, one per story paragraph. A frame whose slice ends inside a +/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style +/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or +/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break). +pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec { + let mut out: Vec = vec![]; + let mut prev_empty_tail = true; + for (k, f) in frames.iter().enumerate() { + let mut styles = f.paragraph_styles().into_iter(); + if k > 0 + && let Some(first) = styles.next() + && (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false)) + && let Some(last) = out.last_mut() + { + *last = first; + } + out.extend(styles); + let text = f.plain_text(); + prev_empty_tail = text.is_empty() || text.ends_with('\n') || out.is_empty(); + } + out +} + +/// Paragraph styles of the slice `runs` of a story starting at story paragraph `first`. +fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec { + let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::(); + let fallback = paras.last().cloned().unwrap_or_default(); + (first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect() +} + /// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph /// break stays with the line that ends there). fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { @@ -37,21 +67,43 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { end } -/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty -/// slices keep one empty run carrying the style at the split, so typing there has a style. -pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec> { +/// A frame's share of a story: its runs and its paragraphs' styles (one per paragraph). +#[derive(Clone, Debug, PartialEq)] +pub struct Slice { + pub runs: Vec, + pub paras: Vec, +} + +/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each +/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices +/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph +/// split between frames has its style in both. +pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec { let mut rest = runs.to_vec(); let mut out = Vec::with_capacity(frames.len()); + let mut next_para = 0; for (i, f) in frames.iter().enumerate() { + let first_para = next_para; let len = runs_len(&rest); - let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) }; + let own = [f.para.clone()]; + let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; + let n = if i + 1 == frames.len() { + len + } else { + let mut probe = (*f).clone(); + probe.runs = rest.clone(); + probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); + fit(db, &probe, &rest) + }; let mut head = slice_runs(&rest, 0, n); if head.is_empty() { head.push(TextRun { text: String::new(), style: style_at(&rest, n) }); } let tail = slice_runs(&rest, n, len); rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail }; - out.push(head); + let head_paras = slice_paras(paras, first_para, &head); + next_para += head_paras.len().saturating_sub(1); + out.push(Slice { runs: head, paras: head_paras }); } out } @@ -59,7 +111,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec< #[cfg(test)] mod tests { use super::*; - use vectorcraft_doc::{CharStyle, TextKind}; + use vectorcraft_doc::{CharStyle, Justify, TextKind}; use vectorcraft_geom::{Affine, Rect, shapes}; fn frame(x: f64) -> TextObject { @@ -75,7 +127,7 @@ mod tests { let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0)); let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here."; let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }]; - let parts = distribute(db, &[&a, &b, &c], &story); + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}"); let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); @@ -90,12 +142,43 @@ mod tests { assert!(!parts[1][0].text.starts_with(' ')); } + #[test] + fn paragraph_styles_flow_with_their_paragraphs() { + let db = FontDb::global(); + let (a, b) = (frame(0.0), frame(200.0)); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird."; + let story = vec![TextRun { text, style: CharStyle::default() }]; + let style = |j| ParaStyle { justify: j, ..Default::default() }; + use vectorcraft_doc::Justify::*; + let paras = vec![style(Left), style(Center), style(Right)]; + let parts = distribute(db, &[&a, &b], &story, ¶s); + assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty()); + // The first paragraph overflows into the second frame: it is in both, with its style. + let pa: Vec = parts[0].paras.iter().map(|p| p.justify).collect(); + let pb: Vec = parts[1].paras.iter().map(|p| p.justify).collect(); + assert_eq!(pa, [Left]); + assert_eq!(pb, [Left, Center, Right]); + // Joined again, the shared paragraph counts once. + let mut fa = a.clone(); + fa.runs = parts[0].runs.clone(); + fa.set_paragraph_styles(parts[0].paras.clone()); + let mut fb = b.clone(); + fb.runs = parts[1].runs.clone(); + fb.set_paragraph_styles(parts[1].paras.clone()); + assert_eq!(story_paras(&[&fa, &fb], &[]), paras); + // A frame whose shared first paragraph was restyled wins. + fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll); + assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll); + assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left); + } + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); let (a, b) = (frame(0.0), frame(200.0)); let st = CharStyle { size: 20.0, ..Default::default() }; - let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]); + let parts: Vec> = + distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }], &[]).into_iter().map(|s| s.runs).collect(); assert_eq!(parts[0][0].text, "Hi"); assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0)); } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 3c6ebd69..ad985eef 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -44,6 +44,10 @@ enum Mode { struct Typing { base: Vec, cur: Vec, + /// The first byte any edit of the session touched (the text before it is unchanged): the + /// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its + /// style continues), not the next one. + lo: usize, } /// IME marked text inside the typing session, not yet committed. @@ -174,9 +178,10 @@ impl TypeTool { if self.typing.is_none() { out.push(Action::Begin("Typing".into())); } - let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone() }); + let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX }); let len = edit::runs_len(&ty.cur); let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)); + ty.lo = ty.lo.min(a); let caret = match &styled { Some(r) => edit::replace_range_styled(&mut ty.cur, a, b, r), None => edit::replace_range(&mut ty.cur, a, b, insert), @@ -187,7 +192,7 @@ impl TypeTool { // One preview for the whole session: the changed span of base → cur, with its runs. let base: String = ty.base.iter().map(|r| r.text.as_str()).collect(); let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect(); - let (p, s) = common_affixes(&base, &cur); + let (p, s) = common_affixes(&base, &cur, ty.lo); let runs = edit::slice_runs(&ty.cur, p, cur.len() - s); let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) }; out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs}))); @@ -332,11 +337,12 @@ fn char_range_to_bytes(s: &str, r: Range) -> Option> { (a <= b).then_some(a..b) } -/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping). -fn common_affixes(a: &str, b: &str) -> (usize, usize) { +/// Lengths of the common prefix (at most `max_prefix`) and suffix of `a` and `b` (on char +/// boundaries, not overlapping). +fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) { let mut p = 0; for ((i, x), y) in a.char_indices().zip(b.chars()) { - if x != y { + if x != y || i + x.len_utf8() > max_prefix { break; } p = i + x.len_utf8(); diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 8ac03794..b925b0fd 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -221,10 +221,13 @@ fn select_all_and_styled_paste() { #[test] fn common_affixes_are_char_safe() { - assert_eq!(common_affixes("hello", "help"), (3, 0)); - assert_eq!(common_affixes("aXb", "ab"), (1, 1)); - assert_eq!(common_affixes("aaa", "aaaa"), (3, 0)); - assert_eq!(common_affixes("é1", "è1"), (0, 1)); + assert_eq!(common_affixes("hello", "help", usize::MAX), (3, 0)); + assert_eq!(common_affixes("aXb", "ab", usize::MAX), (1, 1)); + assert_eq!(common_affixes("aaa", "aaaa", usize::MAX), (3, 0)); + assert_eq!(common_affixes("é1", "è1", usize::MAX), (0, 1)); + // Return at the end of a paragraph: the span starts where the edit did (before the old break). + assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 3), (3, 4)); + assert_eq!(common_affixes("abc\ndef", "abc\n\ndef", 4), (4, 3)); } #[test] diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 6cef59df..ef44d15b 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -21,16 +21,17 @@ pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5. pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; /// The character style shown by the panel: the selected range's (or the caret's) while the Type -/// tool edits text, else the first selected text object's first run. +/// tool edits text, else the first selected text object's first run. With it, the paragraph +/// attributes of the paragraph the selection starts in (else of the first paragraph). pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> { if let Some((id, a, b)) = text_editing(app) && let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) { - return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para.clone())); + return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone())); } let n = first_selected(app)?; match &n.kind { - NodeKind::Text(t) => Some((t.first_style(), t.para.clone())), + NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())), _ => None, } } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 6788de17..24c3e0d2 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -22,15 +22,18 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [ (Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"), ]; -/// Paragraph attributes apply to the whole text object (ending a Type tool typing session first). +/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool +/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session). fn format(app: &mut VectorcraftApp, p: Value) { para_cmd(app, "text.setFormat", p); } fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) { - if let Some((id, _, _)) = super::character::text_editing(app) { + if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); p["ids"] = json!([id.0]); + p["start"] = json!(a); + p["end"] = json!(b); } app.run(cmd, p).ok(); } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 7a5e3809..6c2f2935 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -26,15 +26,21 @@ fn align_id(a: TabAlign) -> &'static str { } } -/// The first selected text object's stops and the ruler span in points (the frame width for area type). +/// The stops of the caret's paragraph while the Type tool edits text (else the first selected text +/// object's first paragraph) and the ruler span in points (the frame width for area type). fn current(app: &VectorcraftApp) -> Option<(Vec, f64)> { - let n = first_selected(app)?; + let editing = super::character::text_editing(app); + let n = match editing { + Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(), + None => first_selected(app)?, + }; let NodeKind::Text(t) = &n.kind else { return None }; + let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start); let span = match &t.kind { vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()), _ => 360.0, }; - Some((t.para.tabs.clone(), span.max(72.0))) + Some((t.para_at(para).tabs.clone(), span.max(72.0))) } fn stops_json(stops: &[TabStop]) -> Value { @@ -47,9 +53,9 @@ fn stops_json(stops: &[TabStop]) -> Value { } fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) { - if let Some((id, _, _)) = super::character::text_editing(app) { + if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); - app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0]})).ok(); + app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0], "start": a, "end": b})).ok(); } else { app.run("text.tabs.set", json!({"stops": stops_json(stops)})).ok(); } @@ -209,7 +215,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { }; let sel: usize = pstate(ui.ctx(), "tabs-sel"); if menu_item(ui, tl!("Clear All Tabs"), false, !stops.is_empty()) { - app.run("text.tabs.clear", json!({})).ok(); + apply(app, &[]); } if menu_item(ui, tl!("Delete Tab"), false, sel < stops.len()) { stops.remove(sel); diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 4df4fb55..e6e7e475 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -67,11 +67,12 @@ fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option, Map Value { if let Some((id, a, b)) = text_editing(app) { p["id"] = json!(id.0); - if kind == Kind::Char && b > a { + if kind == Kind::Para || b > a { p["start"] = json!(a); p["end"] = json!(b); } diff --git a/docs/mcp.md b/docs/mcp.md index 2aeeaa5e..bcece579 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2306,6 +2306,32 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +## Paragraph attributes per paragraph + +Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation, +punctuation spacing (`mojikumi`), hanging punctuation (`burasagari`), direction, leading model, tab stops and +paragraph style. `text.setStyle {justify}`, `text.setFormat {leftIndent, rightIndent, firstLineIndent, +spaceBefore, spaceAfter, hyphenate, mojikumi, burasagari, direction, leadingModel}`, `text.tabs.set` / `text.tabs.clear` and `paraStyle.apply` take optional +`start` / `end` byte offsets of the plain text (as `text.setRangeStyle` does): they change the paragraphs the range +touches (a caret, `start == end`, touches its paragraph). Without a range they change every paragraph of the +targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style +that range of characters. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the +paragraph at `start`; `paraStyle.list` counts paragraphs as uses. A Return the Type tool types (or `text.editRange` +inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; +`text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as +the story re-flows (a paragraph split between frames has them in both). + +In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every +paragraph is alike) one entry per paragraph, so older readers keep the first paragraph's. SVG export anchors each +centred or right-aligned line on its own (``), and SVG import gives each line's paragraph its +alignment back, with indents that keep it in place. + +```json +{"name":"run_command","arguments":{"command":"text.setStyle","params":{"id":42,"justify":"center","start":0,"end":0}}} +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[42],"spaceBefore":6,"start":12,"end":30}}} +{"name":"run_command","arguments":{"command":"paraStyle.apply","params":{"name":"Heading","id":42,"start":0,"end":0}}} +``` + ## Constrain proportions The link between W and H in the Transform panel, the Properties panel and the Control bar is the From a0d5d72d9045e4976c03846f29ff7acbcc2d8257 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:09:31 +0700 Subject: [PATCH 40/74] Fix #593: anchors and handle ends are easier to grab than the segments through them An anchor was picked within the selection tolerance (3 px), the same reach as its segments: a press a little off the anchor's 5 px square took the curve instead. - `ToolContext::point_tol`: anchors and direction handle ends are picked within the selection tolerance, but at least 2 px past the drawn point (4.5 px at the default Size 3, more with a larger Size). Anchors are tested before segments, so where both are within reach the anchor wins. - Direct Selection (presses, Highlight anchors on mouse over, blend spine points and handles), the Anchor Point, Delete Anchor Point and Scissors tools, and the Pen (its Alt conversions; its close/continue reach stays 5 px unless the points are drawn larger) use it. Segments, objects and frame edges keep the selection tolerance. - Of the topmost path with anchors in reach, the nearest anchor is picked. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/tooling.rs | 1 + crates/tools/src/direct.rs | 52 +++++++++++++++++++++++++------- crates/tools/src/draw2/anchor.rs | 11 ++++--- crates/tools/src/lib.rs | 11 +++++++ crates/tools/src/pen.rs | 12 ++++---- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 66983a40..b5864378 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); 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), anchors and handle ends picked from 2 px past their drawn size before the segments through them (#593); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index bd624689..b4d90f1d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -205,6 +205,7 @@ impl Session { slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, selection_tolerance: self.prefs.selection_tolerance, + anchor_size: self.prefs.anchor_size, path_only: self.prefs.object_selection_by_path_only, type_path_only: self.prefs.type_selection_by_path_only, double_click_isolate: self.prefs.double_click_to_isolate, diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index b5c8426a..cf17647c 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -201,15 +201,21 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec { v } -/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is). +/// The anchor within `tol` of `p` nearest to it, of the topmost visible path or area type frame +/// that has one: (id, si, ai, where it is). fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> { let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n)); owners.into_iter().find_map(|id| { let pd = cx.doc.node(id).and_then(editable_path)?; - pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p)) + nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a)) }) } +/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is). +fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> { + pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p))) +} + /// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec)> { anchor_owners(cx, is_area_type).into_iter().find_map(|id| { @@ -290,7 +296,8 @@ impl Tool for DirectSelectionTool { return out; } let p = ev.pos; - let tol = cx.pick_tol(); + // Anchors and handle ends are picked from a little further than segments and objects. + let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (ev.kind, self.state.clone()) { (PointerKind::Down, _) if self.group => { if let Some(out) = self.guide.press(cx, ev) { @@ -332,7 +339,7 @@ impl Tool for DirectSelectionTool { return vec![]; } if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) - && let Some(out) = hit_spine_handle(cx, sel, p, tol) + && let Some(out) = hit_spine_handle(cx, sel, p, point) { self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out }; return vec![Action::Begin("Reshape Spine".into())]; @@ -343,17 +350,17 @@ impl Tool for DirectSelectionTool { return self.mesh.press(g); } self.mesh.unfocus(); - if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { self.state = State::Handle { id, si, ai, out }; self.handle_snap = HandleSnap::default(); return vec![Action::Begin("Reshape".into())]; } - if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) { + if let Some((id, anchor, from)) = hit_spine_point(cx, p, point) { self.spine = Some((id, anchor)); self.state = State::SpinePoint { id, anchor, from, start: p, began: false }; return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } - if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) { + if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) { if cx.doc.node(id).is_some_and(is_area_type) { return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); } @@ -608,14 +615,14 @@ impl Tool for DirectSelectionTool { } } -/// The anchor within the selection tolerance of `p`, of a selected path or the path under `p`: -/// what Highlight anchors on mouse over marks (a press there picks it). +/// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the +/// path under `p`: what Highlight anchors on mouse over marks (a press there picks it). fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> { let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf); - let tol = cx.pick_tol(); + let tol = cx.point_tol(); cx.selection.objects.iter().copied().chain(under).find_map(|id| { let path = cx.doc.node(id).and_then(editable_path)?; - path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p)) + nearest_anchor(&path, p, tol).map(|(.., a)| (id, a)) }) } @@ -685,6 +692,29 @@ mod tests { assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); } + /// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and + /// further with a larger anchor Size or Tolerance (#593); away from it the segment is. + #[test] + fn anchors_are_picked_before_the_segments_through_them() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); + let c = cx(&d, &s, &p); + // 4.3 px from the corner, 0.5 px from the top edge. + assert_eq!(press(&c, 104.2, 100.5), corner); + assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); + assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); + let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; + assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); + let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&loose, 107.5, 100.5), corner); + // On screen: at 200% the reach is half as far in the document. + let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&zoomed, 102.1, 100.2), corner); + assert_ne!(press(&zoomed, 102.6, 100.2), corner); + } + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index c2c2d5ba..53bf528f 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -48,7 +48,7 @@ impl AnchorTool { } fn delete(cx: &ToolContext, p: Point) -> Vec { - hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect() + hit_anchor(cx, p, cx.point_tol()).map(remove_anchor).into_iter().collect() } } @@ -61,7 +61,8 @@ impl Tool for AnchorTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; - let tol = cx.pick_tol(); + // Anchors and handle ends are picked from a little further than segments. + let (tol, point) = (cx.pick_tol(), cx.point_tol()); match (self.id, ev.kind) { ("addAnchor", PointerKind::Down) => { if ev.mods.alt { @@ -78,7 +79,7 @@ impl Tool for AnchorTool { } } ("scissors", PointerKind::Down) => { - if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + if let Some((id, si, ai)) = hit_anchor(cx, p, point) { return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))]; } match hit_segment(cx, p, tol) { @@ -87,10 +88,10 @@ impl Tool for AnchorTool { } } ("anchorPoint", PointerKind::Down) => { - if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { + if let Some((id, si, ai, out)) = hit_handle(cx, p, point) { self.state = State::Handle { id, si, ai, out, began: false }; self.snap = HandleSnap::default(); - } else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + } else if let Some((id, si, ai)) = hit_anchor(cx, p, point) { self.state = State::Convert { id, si, ai, start: p, began: false }; } else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) { self.state = State::Reshape { id, si, seg, t, start: p, began: false }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c3cb04b9..11ed1106 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -274,6 +274,8 @@ pub struct ToolContext<'a> { /// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path /// or an anchor to pick it. pub selection_tolerance: f64, + /// Selection & Anchor Display → Size (1–7, default 3): how big anchors and handles are drawn. + pub anchor_size: u32, /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path /// doesn't pick it, only one on its path does. pub path_only: bool, @@ -362,6 +364,14 @@ impl ToolContext<'_> { pub fn pick_tol(&self) -> f64 { self.tol(self.selection_tolerance) } + /// How near (document units) a press picks an anchor or a direction handle's end: the + /// selection tolerance, but at least 2 px past the drawn point, so a point is easy to catch + /// and wins over the segments through it, which are tested after it (#593). + pub fn point_tol(&self) -> f64 { + // Anchors are drawn 5 px wide at the default Size 3, a pixel more or less per step. + let half = (5.0 + f64::from(self.anchor_size.clamp(1, 7)) - 3.0) / 2.0; + self.tol(self.selection_tolerance.max(half + 2.0)) + } pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { vectorcraft_doc::hit::HitOptions { tol: self.pick_tol(), @@ -666,6 +676,7 @@ pub(crate) mod testutil { slices_locked: false, auto_add_delete: true, selection_tolerance: 3.0, + anchor_size: 3, path_only: false, type_path_only: false, double_click_isolate: true, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index b69537dc..71b05f2f 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -91,11 +91,11 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> { /// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's /// gesture, within its tolerance. fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool { - let tol = cx.pick_tol(); + let point = cx.point_tol(); m.alt - && (hit_handle(cx, p, tol).is_some() - || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some() - || crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some()) + && (hit_handle(cx, p, point).is_some() + || crate::draw2::anchor_in(cx, editable_paths(cx), p, point).is_some() + || crate::draw2::segment_in(cx, editable_paths(cx), p, cx.pick_tol()).is_some()) } /// The selected paths the pen edits (not guides, nor locked or hidden ones). @@ -123,7 +123,7 @@ impl Tool for PenTool { self.drag.is_some() || self.handle.is_some() || self.convert.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let tol = cx.tol(5.0); + let tol = cx.tol(5.0).max(cx.point_tol()); // The path a press began is the selected one by the next event. if self.drawing && self.path.is_none() { self.path = active_path(cx).map(|a| a.0); @@ -297,7 +297,7 @@ impl Tool for PenTool { o } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - let tol = cx.tol(5.0); + let tol = cx.tol(5.0).max(cx.point_tol()); if let Some((id, first, last, _)) = self.active(cx) { let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0); if p.distance(first) <= tol && !one { From 1a1119b1d40fd83e2922a77922f5ff96e4650019 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:13:15 +0700 Subject: [PATCH 41/74] Tests: Arabic joining tests pick an OpenType (GSUB) font macOS's Geeza Pro is an AAT font: its morx shaping takes no context from outside the run, so different_style_arabic_boundary_receives_joining_context failed on Macs (reported in #610). The tests now use the first candidate with a GSUB table and skip when there is none. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tests_bidi.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 0f088423..d73bbb62 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -10,10 +10,16 @@ fn text_layout(s: &str) -> TextLayout { fn arabic_test_face(test: &str) -> Option> { let db = FontDb::global(); + // OpenType (GSUB) shaping only: AAT fonts such as macOS's Geeza Pro shape with `morx`, which + // takes no context from outside the run, so their joining across a style boundary differs. + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa().is_some_and(|f| f.gsub().is_ok()) + }; let face = ["Geeza Pro", "Arial", "Noto Sans Arabic"] .into_iter() .filter_map(|name| db.face(name, "Regular")) - .find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا')); + .find(|face| face.covers('ب') && face.covers('ل') && face.covers('ا') && opentype(face)); if let Some(face) = &face { let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} ({path})", face.family, face.style); From e0b1f55250ec3fadc452ef29fa3b4cce8a0aa7f2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 8 Oct 2026 15:18:57 -0700 Subject: [PATCH 42/74] Swatches: swatch exchange (.ase) files open as swatch libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open, Other Library… and the User Defined library folder read .ase files. Colors keep their model, global and spot colors stay global and spot, and color groups stay groups. The library readers take a file's bytes. On the web, File › Open passes a swatch library file to swatch.library.load as dataBase64 instead of as lossy UTF-8 text. Swatch and group names in a library are now unique, in .gpl files too. Add to Swatches puts each color group of a library into one document group when the document already has a swatch with the group's name. The library fuzz helpers fail when a command panics. --- ROADMAP.md | 2 +- crates/color/src/palette_io.rs | 340 ++++++++++++++++++++- crates/engine/src/cmd/stylelib.rs | 4 +- crates/engine/src/cmd/swatchlib.rs | 38 ++- crates/engine/src/tests_swatchlib.rs | 68 +++++ crates/engine/tests/import_fuzz.rs | 99 ++++-- crates/testkit/src/ase.rs | 61 ++++ crates/testkit/src/lib.rs | 2 + crates/ui-egui/src/io.rs | 2 + crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 12 + docs/control-protocol.md | 2 +- docs/development.md | 2 +- docs/mcp.md | 4 +- 14 files changed, 591 insertions(+), 47 deletions(-) create mode 100644 crates/testkit/src/ase.rs diff --git a/ROADMAP.md b/ROADMAP.md index 66983a40..45d2d7f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 and loaded as `.vcswatches`, `.gpl` or CSS. 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` 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). 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 9d75bc8b..1bdfc9fb 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -1,6 +1,9 @@ //! 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). +//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read +//! only ([`read_bytes`]). + +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; @@ -58,6 +61,15 @@ struct NativeFile { const NATIVE_FORMAT: &str = "vcswatches"; const GPL_HEADER: &str = "GIMP Palette"; const GPL_GROUP: &str = "# Group:"; +const ASE_SIGNATURE: &[u8] = b"ASEF"; +const ASE_GROUP_START: u16 = 0xC001; +const ASE_GROUP_END: u16 = 0xC002; +const ASE_COLOR: u16 = 0x0001; +/// The most colors and groups read from one `.ase` file. +const ASE_MAX_ENTRIES: usize = 100_000; +/// The most UTF-16 code units of a name that are kept. +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. @@ -199,16 +211,34 @@ pub fn sniff(text: &str) -> bool { t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT)) } +/// Read the bytes of a library file: a swatch exchange `.ase` file, else a `.vcswatches` or +/// `.gpl` library ([`read`]). `name` names it when the file doesn't. +pub fn read_bytes(bytes: &[u8], name: &str) -> Result { + if bytes.starts_with(ASE_SIGNATURE) { + return read_ase(bytes, name); + } + read(&String::from_utf8_lossy(bytes), name) +} + +/// Are `bytes` a library [`read_bytes`] understands? Text that isn't valid UTF-8 is checked as +/// [`read_bytes`] reads it, with the invalid bytes replaced (its first KiB is enough). +pub fn sniff_bytes(bytes: &[u8]) -> bool { + bytes.starts_with(ASE_SIGNATURE) + || std::str::from_utf8(bytes).is_ok_and(sniff) + || sniff(&String::from_utf8_lossy(bytes.get(..1024).unwrap_or(bytes))) +} + /// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and -/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are -/// named by their values; repeated names get a number. +/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colors are +/// named by their values and unnamed groups "Color Group"; a name a color or group already has gets +/// a number ([`UniqueNames`]). fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() }; - let mut names: Vec = vec![]; + let mut names = UniqueNames::default(); for line in text.lines().skip(1) { let line = line.trim(); if let Some(g) = line.strip_prefix(GPL_GROUP) { - lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] }); + lib.groups.push(SwatchGroup { name: names.unique(group_name(g)), swatches: vec![] }); continue; } if let Some(n) = line.strip_prefix("Name:") { @@ -221,9 +251,7 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { let [r, g, b] = rgb[..] else { continue }; let rest = words[3..].join(" "); let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest }; - let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base); - names.push(name.clone()); - let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false }; + let w = Swatch { name: names.unique(base), paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false }; match lib.groups.last_mut() { Some(grp) => grp.swatches.push(w), None => lib.swatches.push(w), @@ -232,6 +260,184 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { lib } +/// `.ase`, from public descriptions of the format: the layout from +/// , the component ranges from +/// . After the signature come, big-endian, a `u16` major (1) and +/// minor version, a `u32` block count and the blocks, each a `u16` type, a `u32` body length and +/// the body. A group start's body is a name. A color's body is a name, a four-character model +/// (`RGB `, `CMYK`, `LAB `, `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16` +/// color type (0 global, 1 spot, 2 process). RGB, CMYK and gray components run from 0 to 1 (a +/// gray level of 1 is white), Lab lightness from 0 to 1 and a and b from −128 to 127. A lightness +/// above 1 is read as L* itself. A name is a `u16` count of UTF-16 code units, the terminating zero +/// included, then the units. +/// +/// Color groups become groups: a group start inside an open group ends that group, and a group +/// left open ends with the file. Blocks of other types and colors in other models are skipped. +/// Unnamed colors are named by their values and unnamed groups "Color Group"; a name a color or +/// group already has gets a number ([`UniqueNames`]). A truncated file, a major version other than +/// 1 or more than [`ASE_MAX_ENTRIES`] colors and groups is an error. +fn read_ase(bytes: &[u8], fallback: &str) -> Result { + let mut data = AseData(bytes); + data.bytes(ASE_SIGNATURE.len())?; + let (major, minor) = (data.u16()?, data.u16()?); + if major != 1 { + return Err(format!("swatch exchange version {major}.{minor} isn't supported (only 1.x)")); + } + let blocks = data.u32()?; + let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() }; + let mut names = UniqueNames::default(); + let (mut in_group, mut entries) = (false, 0usize); + for _ in 0..blocks { + let kind = data.u16()?; + let len = usize::try_from(data.u32()?).unwrap_or(usize::MAX); + let mut body = AseData(data.bytes(len)?); + if matches!(kind, ASE_GROUP_START | ASE_COLOR) { + entries += 1; + if entries > ASE_MAX_ENTRIES { + return Err(format!("the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups")); + } + } + match kind { + ASE_GROUP_START => { + lib.groups.push(SwatchGroup { name: names.unique(group_name(&body.name()?)), swatches: vec![] }); + in_group = true; + } + ASE_GROUP_END => in_group = false, + ASE_COLOR => { + let Some(mut w) = ase_swatch(&mut body)? else { continue }; + w.name = names.unique(std::mem::take(&mut w.name)); + match lib.groups.last_mut().filter(|_| in_group) { + Some(g) => g.swatches.push(w), + None => lib.swatches.push(w), + } + } + // Other blocks are skipped. + _ => {} + } + } + Ok(lib) +} + +/// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray. +/// Components are clamped to their ranges. +fn ase_swatch(body: &mut AseData) -> Result, String> { + let name = body.name()?; + let unit = |v: f32| v.clamp(0.0, 1.0); + let color = match &body.array::<4>()? { + b"RGB " => { + let [r, g, b] = [body.f32()?, body.f32()?, body.f32()?]; + Color::rgb(unit(r), unit(g), unit(b)) + } + b"CMYK" => { + let [c, m, y, k] = [body.f32()?, body.f32()?, body.f32()?, body.f32()?]; + Color::cmyk(unit(c), unit(m), unit(y), unit(k)) + } + // Lightness is stored as a fraction of 100. Some writers store L* itself, which shows as a + // value above 1. + b"LAB " => { + let [l, a, b] = [body.f32()?, body.f32()?, body.f32()?]; + let l = if l > 1.0 { l.min(100.0) } else { unit(l) * 100.0 }; + Color::lab(l, a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0)) + } + // A gray level (1 is white); a gray color holds its ink. + b"Gray" => Color::gray(1.0 - unit(body.f32()?)), + _ => return Ok(None), + }; + // Some writers leave the color type out, which makes a process color. + let kind = if body.0.len() >= 2 { body.u16()? } else { 2 }; + // Spot colors are global. + let (global, spot) = match kind { + 0 => (true, false), + 1 => (true, true), + _ => (false, false), + }; + let name = if name.is_empty() { value_name(color) } else { name }; + Ok(Some(Swatch { name, paint: Paint::solid(color), global, spot })) +} + +/// A color's values in its own model, as new swatches are named ("C=10 M=20 Y=30 K=0", +/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30"). +fn value_name(c: Color) -> String { + let pct = |v: f32| (v * 100.0).round(); + let byte = |v: f32| (v * 255.0).round(); + match c { + Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)), + Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)), + Color::Gray { k } => format!("Gray K={}", pct(k)), + // `+ 0.0` turns a rounded -0 into 0. + Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0), + } +} + +/// `.ase` data, read field by field; reading past its end is an error. +struct AseData<'a>(&'a [u8]); + +impl<'a> AseData<'a> { + fn bytes(&mut self, n: usize) -> Result<&'a [u8], String> { + let (head, rest) = self.0.split_at_checked(n).ok_or(ASE_CUT_SHORT)?; + self.0 = rest; + Ok(head) + } + fn array(&mut self) -> Result<[u8; N], String> { + let (head, rest) = self.0.split_first_chunk::().ok_or(ASE_CUT_SHORT)?; + self.0 = rest; + Ok(*head) + } + fn u16(&mut self) -> Result { + self.array().map(u16::from_be_bytes) + } + fn u32(&mut self) -> Result { + self.array().map(u32::from_be_bytes) + } + /// A component; one that isn't a finite number reads as 0. + fn f32(&mut self) -> Result { + let v = f32::from_be_bytes(self.array()?); + Ok(if v.is_finite() { v } else { 0.0 }) + } + /// A name up to its terminating zero, trimmed. A block that ends before it has no name. + fn name(&mut self) -> Result { + if self.0.is_empty() { + return Ok(String::new()); + } + let n = usize::from(self.u16()?); + let (units, _) = self.bytes(n * 2)?.as_chunks::<2>(); + let units: Vec = units.iter().take(ASE_MAX_NAME).map(|u| u16::from_be_bytes(*u)).take_while(|&u| u != 0).collect(); + Ok(String::from_utf16_lossy(&units).trim().to_string()) + } +} + +/// A group's name as a file gives it, trimmed; "Color Group" when it has none. +fn group_name(name: &str) -> String { + let name = name.trim(); + if name.is_empty() { "Color Group".into() } else { name.to_string() } +} + +/// The swatch and group names a reader has handed out. Swatches and color groups share one set of +/// names, as in a document, and a name already taken gets the first free number ("Black 2"). +#[derive(Default)] +struct UniqueNames { + taken: HashSet, + /// The last number tried for each repeated name. + last: HashMap, +} + +impl UniqueNames { + fn unique(&mut self, base: String) -> String { + if self.taken.insert(base.clone()) { + return base; + } + // Each pass tries the next number, and only finitely many names are taken. + let n = self.last.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base} {n}"); + if self.taken.insert(name.clone()) { + return name; + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -294,6 +500,11 @@ mod tests { assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]); assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255))); assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback"); + // Colors and groups share names; an unnamed group is "Color Group". + let lib = read("GIMP Palette\n0 0 0 Black\n# Group: Black\n1 1 1 Black\n# Group:\n", "x").unwrap(); + let groups: Vec<(&str, Vec<&str>)> = + lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Black 2", vec!["Black 3"]), ("Color Group", vec![])]); } #[test] @@ -321,4 +532,117 @@ mod tests { assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;")); assert!(read(&text, "x").is_err(), "CSS is written only"); } + + /// A swatch exchange file of `blocks` (type, body). + fn ase(blocks: &[(u16, Vec)]) -> Vec { + let mut out = b"ASEF\0\x01\0\0".to_vec(); + out.extend((blocks.len() as u32).to_be_bytes()); + for (kind, body) in blocks { + out.extend(kind.to_be_bytes()); + out.extend((body.len() as u32).to_be_bytes()); + out.extend(body); + } + out + } + + /// A name as `.ase` stores it. + fn ase_name(name: &str) -> Vec { + let units: Vec = name.encode_utf16().chain([0]).collect(); + let mut out = (units.len() as u16).to_be_bytes().to_vec(); + out.extend(units.iter().flat_map(|u| u.to_be_bytes())); + out + } + + /// A color block: name, model, components and color type. + fn ase_color(name: &str, model: &[u8; 4], values: &[f32], kind: u16) -> (u16, Vec) { + let mut body = ase_name(name); + body.extend(model); + body.extend(values.iter().flat_map(|v| v.to_be_bytes())); + body.extend(kind.to_be_bytes()); + (ASE_COLOR, body) + } + + #[test] + fn ase_reads_color_models_kinds_and_groups() { + let bytes = 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_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_color("Paper", b"RGB ", &[1.0, 1.0, 1.0], 2), + ]); + assert!(sniff_bytes(&bytes)); + let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot }; + let expected = SwatchLibrary { + name: "Brand".into(), + swatches: vec![ + sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false), + sw("Ink", Color::cmyk(1.0, 0.5, 0.0, 0.2), true, true), + sw("Paper", Color::WHITE, false, false), + ], + groups: vec![SwatchGroup { + name: "Neutrals".into(), + swatches: vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)], + }], + }; + assert_eq!(read_bytes(&bytes, "Brand").unwrap(), expected, "named after the file; 0 global, 1 spot, 2 process"); + // A lightness above 1 is L* itself. + let deep = read_bytes(&ase(&[ase_color("Deep", b"LAB ", &[53.0, 20.0, -30.0], 2)]), "x").unwrap(); + assert_eq!(deep.swatches[0].paint.color(), Some(Color::lab(53.0, 20.0, -30.0))); + // Text libraries read through the same call, also when they aren't valid UTF-8. + assert!(sniff_bytes(b"GIMP Palette\n1 2 3 x\n") && !sniff_bytes(b"ASE") && !sniff_bytes(&[0xff, 0xfe, 0])); + assert_eq!(read_bytes(b"GIMP Palette\n1 2 3 x\n", "F").unwrap().swatches.len(), 1); + let latin1 = b"GIMP Palette\n1 2 3 Caf\xe9\n"; + assert!(sniff_bytes(latin1)); + assert_eq!(read_bytes(latin1, "F").unwrap().swatches.len(), 1); + } + + #[test] + fn ase_names_unnamed_and_repeated_entries_and_skips_other_blocks() { + let mut untyped = ase_color("Plain", b"RGB ", &[0.2, 0.4, 0.6], 2); + untyped.1.truncate(untyped.1.len() - 2); + let bytes = ase(&[ + ase_color("Café 🎨", b"RGB ", &[1.0, 0.0, 0.0], 2), + ase_color("", b"CMYK", &[0.1, 0.2, 0.3, 0.0], 2), + ase_color(" Red ", b"RGB ", &[2.0, -1.0, f32::NAN], 0), + ase_color("Red", b"Gray", &[0.0], 2), + untyped, + ase_color("Hue", b"HSB ", &[0.1, 0.2, 0.3], 2), + (0x0042, vec![1, 2, 3]), + (ASE_GROUP_END, vec![]), + (ASE_GROUP_START, ase_name("")), + (ASE_GROUP_START, ase_name("Red")), + ase_color("Red", b"Gray", &[1.0], 2), + ]); + let lib = read_bytes(&bytes, "x").unwrap(); + let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, ["Café 🎨", "C=10 M=20 Y=30 K=0", "Red", "Red 2", "Plain"], "an HSB color and an unknown block are skipped"); + assert_eq!(lib.swatches[2].paint.color(), Some(Color::rgb(1.0, 0.0, 0.0)), "clamped, NaN read as 0"); + assert!(lib.swatches[2].global && !lib.swatches[4].global, "without a color type: process"); + // A group start ends the open group, and groups and colors share names. + let groups: Vec<(&str, Vec<&str>)> = + lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Color Group", vec![]), ("Red 3", vec!["Red 4"])]); + assert_eq!(lib.groups[1].swatches[0].paint.color(), Some(Color::gray(0.0)), "gray level 1 is white"); + } + + #[test] + fn damaged_ase_files_are_errors() { + let good = ase(&[(ASE_GROUP_START, ase_name("G")), ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0)]); + assert_eq!(read_bytes(&good, "x").unwrap().len(), 1); + for n in ASE_SIGNATURE.len()..good.len() { + assert!(read_bytes(&good[..n], "x").is_err(), "cut at {n}"); + } + let mut v2 = good.clone(); + v2[5] = 2; + assert!(read_bytes(&v2, "x").unwrap_err().contains("2.0"), "version 2.0"); + // Too many colors, or too many groups (empty group starts take 6 bytes each). + let colors: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| ase_color("Gray", b"Gray", &[0.5], 2)).collect(); + assert!(read_bytes(&ase(&colors), "x").unwrap_err().contains("more than")); + let groups: Vec<(u16, Vec)> = (0..=ASE_MAX_ENTRIES).map(|_| (ASE_GROUP_START, vec![])).collect(); + 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"); + } } diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 3b8c34e0..79fa4f94 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -70,8 +70,8 @@ pub fn specs() -> Vec { impl LibraryFile for StyleLibrary { const EXTS: &'static [&'static str] = &[STYLES_EXT]; - fn read(text: &str, stem: &str) -> std::result::Result { - style_libs::read(text, stem) + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + style_libs::read(&String::from_utf8_lossy(bytes), stem) } fn name(&self) -> &str { &self.name diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 171aee02..35d39516 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -69,7 +69,7 @@ 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 or .gpl 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) → {library: id, name, count}", always, load ), @@ -98,23 +98,24 @@ struct Extra { pub trait LibraryFile: Sized { /// The extensions of its files (lower case). const EXTS: &'static [&'static str]; - /// Read a library file whose name without the extension is `stem` (an unnamed library's name). - fn read(text: &str, stem: &str) -> std::result::Result; + /// Read the bytes of a library file whose name without the extension is `stem` (an unnamed + /// library's name). + fn read(bytes: &[u8], stem: &str) -> std::result::Result; fn name(&self) -> &str; } impl LibraryFile for SwatchLibrary { const EXTS: &'static [&'static str] = LIBRARY_EXTS; - fn read(text: &str, stem: &str) -> std::result::Result { - palette_io::read(text, stem) + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + palette_io::read_bytes(bytes, stem) } fn name(&self) -> &str { &self.name } } -/// The extensions of library files [`palette_io::read`] reads. -pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl"]; +/// The extensions of library files [`palette_io::read_bytes`] reads. +pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"]; impl Libraries { pub fn user_dir(&self) -> Option<&str> { @@ -133,7 +134,7 @@ impl Libraries { let Some(dir) = self.user_dir.clone() else { return }; for path in library_files(&dir, L::EXTS) { let file = file_name(&path); - let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&String::from_utf8_lossy(&b), stem(&file)).ok()) else { continue }; + let Some(lib) = read_file(&path).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue }; let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" }; self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); } @@ -351,12 +352,11 @@ fn load(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.library.load"; let (info, lib) = s.swatch_libraries.load(p, C, |bytes, file| { // A library file, or a document whose swatches become the library. - match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) { - Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)), - None => { - let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc; - document_library(&doc, &[], stem(file).to_string(), C) - } + if palette_io::sniff_bytes(bytes) { + palette_io::read_bytes(bytes, stem(file)).map_err(|e| bad(C, e)) + } else { + let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc; + document_library(&doc, &[], stem(file).to_string(), C) } })?; Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) @@ -417,12 +417,17 @@ fn add(s: &mut Session, p: &Value) -> Result { } else { s.edit("Add to Swatches", |d, _| { let mut out = vec![]; + // Each library group goes into one document group: the group of that name the document + // already had, or one made here. + let before = d.swatch_groups.len(); + let mut placed: Vec<(&str, usize)> = vec![]; for (group, w) in fresh { let name = d.free_swatch_name(&w.name); let sw = Swatch { name: name.clone(), ..(*w).clone() }; match group { Some(g) => { - let i = match d.swatch_groups.iter().position(|x| x.name == *g) { + let found = placed.iter().find(|(n, _)| *n == g).map(|&(_, i)| i); + let i = match found.or_else(|| d.swatch_groups.iter().take(before).position(|x| x.name == *g)) { Some(i) => i, None => { let name = d.free_swatch_name(g); @@ -430,6 +435,9 @@ fn add(s: &mut Session, p: &Value) -> Result { d.swatch_groups.len() - 1 } }; + if found.is_none() { + placed.push((g, i)); + } d.swatch_groups[i].swatches.push(sw); } None => d.swatches.push(sw), diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index 224c475d..ccdcd2d3 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -197,6 +197,74 @@ fn another_documents_swatches_load_as_a_library() { assert!(s.execute("swatch.library.load", &json!({"data": "hello", "name": "x.txt"})).is_err()); } +#[test] +fn swatch_exchange_files_load_add_and_list_as_user_libraries() { + let bytes = vectorcraft_testkit::ase::sample(); + let mut s = session(); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Brand.ase"})); + assert_eq!((r["library"].as_str(), r["name"].as_str(), r["count"].as_u64()), (Some("loaded/Brand.ase"), Some("Brand"), Some(4))); + let g = run(&mut s, "swatch.library.get", json!({"library": r["library"]})); + let swatches = g["swatches"].as_array().unwrap(); + let kinds: Vec<(&str, bool, bool)> = + swatches.iter().map(|w| (w["name"].as_str().unwrap(), w["global"].as_bool().unwrap(), w["spot"].as_bool().unwrap())).collect(); + assert_eq!(kinds, [("Sky", true, false), ("Ink", true, true), ("Mist", false, false), ("Clay", false, false)]); + assert_eq!(swatches[3]["color"], json!({"model": "lab", "l": 50.0, "a": 20.0, "b": -30.0})); + // Added to the document, the spot color stays a spot color and the group stays a color group. + run(&mut s, "swatch.library.add", json!({"library": r["library"]})); + let d = doc(&s); + assert!(d.swatch("Ink").is_some_and(|w| w.spot && w.global)); + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "Neutrals").map(|g| g.swatches.len()), Some(2)); + // A damaged file is an error from the swatch exchange reader, not from the document loader. + let cut = vectorcraft_format::base64_encode(&bytes[..bytes.len() - 1]); + let e = s.execute("swatch.library.load", &json!({"dataBase64": cut, "name": "Cut.ase"})).unwrap_err().to_string(); + assert!(e.contains("cut short"), "{e}"); + // `.ase` files in the user library folder are User Defined libraries. + let dir = temp_dir("ase"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Brand.ase"), &bytes).unwrap(); + s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let list = run(&mut s, "swatch.library.list", json!({})); + let user: Vec<(&str, u64)> = list["libraries"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["category"] == "user") + .map(|l| (l["id"].as_str().unwrap(), l["count"].as_u64().unwrap())) + .collect(); + assert_eq!(user, [("user/Brand.ase", 4)]); + 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}; + let mut s = session(); + assert!(doc(&s).swatch("Blue").is_some(), "a new document has a swatch named Blue"); + let bytes = ase(&[ + Block::GroupStart("Blue"), + Block::Color("Deep", b"RGB ", &[0.0, 0.0, 0.4], 2), + Block::Color("Pale", b"RGB ", &[0.6, 0.7, 1.0], 2), + Block::GroupEnd, + Block::GroupStart("Blue 2"), + Block::Color("Frost", b"RGB ", &[0.9, 0.95, 1.0], 2), + Block::GroupEnd, + ]); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "Blues.ase"})); + run(&mut s, "swatch.library.add", json!({"library": r["library"]})); + let groups: Vec<(&str, Vec<&str>)> = + doc(&s).swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|w| w.name.as_str()).collect())).collect(); + assert!(groups.contains(&("Blue 2", vec!["Deep", "Pale"])), "{groups:?}"); + assert!(groups.contains(&("Blue 2 2", vec!["Frost"])), "the library's own Blue 2 stays apart: {groups:?}"); +} + +#[test] +fn text_libraries_sent_as_bytes_load_when_they_are_not_valid_utf8() { + let mut s = session(); + let latin1 = vectorcraft_format::base64_encode(b"GIMP Palette\n237 28 36 Rouge fonc\xe9\n"); + let r = run(&mut s, "swatch.library.load", json!({"dataBase64": latin1, "name": "Rouge.gpl"})); + assert_eq!(r["count"], 1); +} + #[test] fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradients() { let mut s = session(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 83450a86..52b2a87f 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,5 +1,5 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, -//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset +//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records @@ -529,12 +529,21 @@ fn saved(cmd: &str, params: Value) -> String { rich_session().execute(cmd, ¶ms).unwrap()["data"].as_str().unwrap().to_string() } +/// `r`, after failing the case if the command panicked. The engine's guard reports a panic as +/// `EngineError::Internal`. +fn no_panic(r: Result) -> Result { + if let Err(vectorcraft_engine::EngineError::Internal { cmd, msg }) = &r { + panic!("{cmd} panicked: {msg}"); + } + r +} + /// Library file `data` loaded with `load`; what it holds is then used on the rich document /// (`then`, given the load's result), which renders and exports. fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut vectorcraft_engine::Session, &Value)) -> Result<(), TestCaseError> { survive(what, || { let mut s = rich_session(); - let r = s.execute(load, &json!({"data": data, "name": "fuzz"})).ok()?; + let r = no_panic(s.execute(load, &json!({"data": data, "name": "fuzz"}))).ok()?; then(&mut s, &r); Some((*s.doc().ok()?.doc).clone()) }) @@ -542,27 +551,55 @@ fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut ve fn swatches(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "swatch.library.load", data, |s, r| { - let _ = s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"})); + let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}))); }) } fn styles(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "graphicStyle.loadLibrary", data, |s, r| { - let _ = s.execute("select.all", &json!({})); - let _ = s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true})); + let _ = no_panic(s.execute("select.all", &json!({}))); + let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true}))); }) } fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "flattener.presets.import", data, |s, r| { - let _ = s.execute("select.all", &json!({})); + let _ = no_panic(s.execute("select.all", &json!({}))); if let Some(name) = r["imported"].get(0) { - let _ = s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"})); - let _ = s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36})); + let _ = no_panic(s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}))); + let _ = no_panic(s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}))); } }) } +/// Swatch exchange (`.ase`) bytes loaded as a swatch library, then added to the rich document. +fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = rich_session(); + let load = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.ase"}); + let r = no_panic(s.execute("swatch.library.load", &load)).ok()?; + let _ = no_panic(s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}))); + Some((*s.doc().ok()?.doc).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)> { + let kind = prop::sample::select(vec![0xC001u16, 0xC002, 0x0001, 0x0042]); + let model = prop::sample::select(vec![*b"RGB ", *b"CMYK", *b"LAB ", *b"Gray", *b"HSB "]); + (kind, "[ -~]{0,8}", model, prop::collection::vec(any::(), 0..24)).prop_map(|(kind, name, model, rest)| { + let units: Vec = name.encode_utf16().chain([0]).collect(); + let mut body = (units.len() as u16).to_be_bytes().to_vec(); + body.extend(units.iter().flat_map(|u| u.to_be_bytes())); + if kind == 0x0001 { + body.extend(model); + } + body.extend(rest); + (kind, body) + }) +} + /// Characters that break JSON and GPL palettes. fn arb_edit() -> impl Strategy { (0usize..20_000, prop::sample::select(vec!['{', '}', '[', ']', '"', ':', ',', '-', '9', 'e', '.', ' ', '\n', '#', 'n', 'x', '\t'])) @@ -596,6 +633,32 @@ proptest! { let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]})); flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?; } + + #[test] + fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::(), 0..16)) { + let mut bytes = b"ASEF\0\x01\0\0".to_vec(); + bytes.extend((blocks.len() as u32).to_be_bytes()); + for (kind, body) in &blocks { + bytes.extend(kind.to_be_bytes()); + bytes.extend((body.len() as u32).to_be_bytes()); + bytes.extend(body); + } + bytes.extend(tail); + swatch_exchange("swatch exchange garbage", &bytes)?; + } + + #[test] + fn mutated_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..200), edits in prop::collection::vec((0usize..200, any::()), 0..12)) { + let mut bytes = vectorcraft_testkit::ase::sample(); + for (at, b) in edits { + let n = bytes.len(); + bytes[at % n] = b; + } + if let Some(cut) = cut { + bytes.truncate(cut); + } + swatch_exchange("mutated swatch exchange file", &bytes)?; + } } /// A small image of each raster format Place reads, with resolution metadata where it has some. @@ -704,8 +767,8 @@ proptest! { fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "pdf.preset.import", data, |s, r| { for name in r["imported"].as_array().into_iter().flatten() { - let _ = s.execute("document.exportPdf", &json!({"preset": name})); - let _ = s.execute("document.save", &json!({"format": "ai", "preset": name})); + let _ = no_panic(s.execute("document.exportPdf", &json!({"preset": name}))); + let _ = no_panic(s.execute("document.save", &json!({"format": "ai", "preset": name}))); } }) } @@ -1219,14 +1282,14 @@ proptest! { /// each one imported. fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "print.presets.import", data, |s, r| { - let list = s.execute("print.presets.list", &json!({})).unwrap_or_default(); + let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default(); for name in r["imported"].as_array().into_iter().flatten() { let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue }; let settings = json!({ "settings": p["settings"] }); - let pages = s.execute("print.preview", &settings).ok().and_then(|v| v["pages"].as_u64()); - let _ = s.execute("print.setup", &settings); + let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64()); + let _ = no_panic(s.execute("print.setup", &settings)); if pages.is_some_and(|n| n <= 8) { - let _ = s.execute("file.print", &settings); + let _ = no_panic(s.execute("file.print", &settings)); } } }) @@ -1256,9 +1319,11 @@ proptest! { fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "perspective.presets.import", data, |s, r| { for name in r["imported"].as_array().into_iter().flatten() { - let _ = s.execute("perspective.grid.preset", &json!({"name": name})); - let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3})); - let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})); + let _ = no_panic(s.execute("perspective.grid.preset", &json!({"name": name}))); + let _ = no_panic(s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3}))); + let _ = no_panic( + s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})), + ); } }) } diff --git a/crates/testkit/src/ase.rs b/crates/testkit/src/ase.rs new file mode 100644 index 00000000..f91ca2a5 --- /dev/null +++ b/crates/testkit/src/ase.rs @@ -0,0 +1,61 @@ +//! Hand-written swatch exchange (`.ase`) files for swatch library tests: version 1.0, big-endian, +//! with colors in each model and color type, and color groups. + +/// One block of a swatch exchange file. +#[derive(Clone, Debug)] +pub enum Block<'a> { + /// A color group starts (its name). + GroupStart(&'a str), + /// The open color group ends. + GroupEnd, + /// A color: its name, model (`b"RGB "`, `b"CMYK"`, `b"LAB "` or `b"Gray"`), components and + /// color type (0 global, 1 spot, 2 process). + Color(&'a str, &'a [u8; 4], &'a [f32], u16), +} + +/// A name: a `u16` count of UTF-16 code units, the terminating zero included, then the units. +fn name(out: &mut Vec, name: &str) { + let units: Vec = name.encode_utf16().chain([0]).collect(); + out.extend(u16::try_from(units.len()).unwrap().to_be_bytes()); + out.extend(units.iter().flat_map(|u| u.to_be_bytes())); +} + +/// A swatch exchange file of `blocks`. +pub fn ase(blocks: &[Block]) -> Vec { + let mut out = b"ASEF\0\x01\0\0".to_vec(); + out.extend(u32::try_from(blocks.len()).unwrap().to_be_bytes()); + for b in blocks { + let mut body = vec![]; + let kind: u16 = match b { + Block::GroupStart(n) => { + name(&mut body, n); + 0xC001 + } + Block::GroupEnd => 0xC002, + Block::Color(n, model, values, kind) => { + name(&mut body, n); + body.extend(*model); + body.extend(values.iter().flat_map(|v| v.to_be_bytes())); + body.extend(kind.to_be_bytes()); + 0x0001 + } + }; + out.extend(kind.to_be_bytes()); + out.extend(u32::try_from(body.len()).unwrap().to_be_bytes()); + out.extend(body); + } + out +} + +/// A library of four colors: "Sky" (global RGB) and "Ink" (spot CMYK), then the group "Neutrals" +/// of the process colors "Mist" (Gray, 25% ink) and "Clay" (Lab 50, 20, −30). +pub fn sample() -> Vec { + ase(&[ + Block::Color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0), + Block::Color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1), + Block::GroupStart("Neutrals"), + Block::Color("Mist", b"Gray", &[0.75], 2), + Block::Color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2), + Block::GroupEnd, + ]) +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 61b63115..704e98b8 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -10,6 +10,7 @@ //! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance. //! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance). //! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests. +//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library 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 @@ -18,6 +19,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![forbid(unsafe_code)] +pub mod ase; pub mod fixtures; pub mod geom; pub mod invariants; diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cf7c08a6..a1f800d0 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -43,6 +43,8 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT { let p = match path { Some(path) => serde_json::json!({ "path": path }), + // `.ase` swatch libraries are binary. + None if swatches => serde_json::json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), }; let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3ee6083d..27b0b3a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -346,7 +346,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.swatchLibrary.other", "Other Library…", "", - "{path?} (default: pick a file) load a .vcswatches or .gpl library, or another document's swatches (engine: swatch.library.load), and open it in the library panel", + "{path?} (default: pick a file) load a .vcswatches, .gpl or .ase library, or another document's swatches (engine: swatch.library.load), and open it in the library panel", ), ( "ui.saveSwatchLibrary", diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index bd68193d..063630ec 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -616,4 +616,16 @@ mod tests { } assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES)); } + + #[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. + let mut app = app(); + crate::io::open_bytes(&mut app, "Brand.ase", &vectorcraft_testkit::ase::sample(), None).unwrap(); + let open = app.ui.library_panel.clone().unwrap(); + assert_eq!(open.id, "loaded/Brand.ase"); + let (_, lib) = SwatchLibraries::get(&app, &open.id).unwrap(); + assert_eq!((lib.len(), lib.groups.len()), (4, 1)); + assert_eq!(app.session.documents().len(), 1, "not opened as a document"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebf984d0..5a5297e5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -154,7 +154,7 @@ 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` or `.gpl` 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. `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). diff --git a/docs/development.md b/docs/development.md index 1cedaaea..00fb8e1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -238,7 +238,7 @@ web build has no net at all. Untrusted input has property tests that must never panic: - `crates/engine/tests/import_fuzz.rs`: garbage, hostile and mutated SVG and PDF, and swatch - (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset (`.vcflattener`) + (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset (`.vcflattener`) libraries, loaded and used, then rendered and exported. - `crates/engine/tests/command_sweep.rs`: every command with junk parameters. - `crates/format/tests/prop_format.rs`: garbage and mutated `.vectorcraft` files. diff --git a/docs/mcp.md b/docs/mcp.md index 16030f18..8b75e83f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -740,7 +740,9 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back 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 folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | -dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from. +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. ## Graphic style libraries From 67af21c6db4b12be38b981a8aa0de6fa468dedf1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 8 Oct 2026 15:18:57 -0700 Subject: [PATCH 43/74] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 02f8742e..c840c669 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,7 @@ sync_github_name = true [people.omd0] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From 54eed6ec4c3704ca58e5369385055debe4f934a5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:22:09 +0700 Subject: [PATCH 44/74] Fix #597: an image copied in a browser pastes as the picture, not its address A browser's Copy Image puts the picture on the clipboard with the image's address as text (and HTML). Paste takes text before bitmaps, as word processors offer a picture of copied text, so it pasted the address. - `clipboard::is_address`: text that is only an address (http, https, file, ftp, data or blob). - Desktop paste (Windows, macOS, Linux): such text gives way to the clipboard's bitmap when it has one. An address alone pastes as text, as before; nothing is fetched. - Web: a paste whose text is only an address takes its picture file. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/vectorcraft-web/src/web.rs | 5 +++-- crates/engine/src/cmd/clipboard.rs | 21 ++++++++++++++++++++- crates/engine/src/cmd/clipboard/flavours.rs | 9 +++++++++ crates/ui-egui/src/sysclip.rs | 4 +++- crates/ui-egui/src/tests_sysclip.rs | 16 ++++++++++++++++ 6 files changed, 52 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7a24fc4a..872564fa 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); 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), an image copied in a browser pasting as the picture, not its address (#597); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index cba1b748..feeb9296 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -6,7 +6,7 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; use vectorcraft_engine::Session; -use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour}; +use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address}; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore}; use vectorcraft_ui_egui::graphics::GraphicsLoss; @@ -200,7 +200,8 @@ fn track_paste(pasted: &Rc>) { let on_paste = wasm_bindgen::closure::Closure::::new(move |e: web_sys::ClipboardEvent| { let Some(data) = e.clipboard_data() else { return }; let Some(files) = data.files().filter(|f| f.length() > 0) else { return }; - if data.get_data("text").is_ok_and(|t| !t.is_empty()) { + // Text is egui's, but for the address a browser's Copy Image puts next to the picture. + if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) { return; } e.prevent_default(); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 4cae4ecb..aaf8d5af 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -21,7 +21,7 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef}; use vectorcraft_geom::Rect; -pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour}; +pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address}; pub(crate) use resources::SwatchChoices; use super::*; @@ -220,6 +220,25 @@ mod tests { s } + /// The address a browser's Copy Image puts next to the picture (#597), and text that isn't one. + #[test] + fn an_address_alone_is_told_from_other_text() { + for a in [ + "https://example.com/cat.png", + " http://x.org/a?b=1 +", + "HTTPS://EXAMPLE.COM", + "file:///C:/art/cat.png", + "data:image/png;base64,iVBOR", + "blob:https://x.org/1", + ] { + assert!(is_address(a), "{a}"); + } + for t in ["Pasted words", "see https://example.com", "https://a.org https://b.org", "https://", "example.com/cat.png", ""] { + assert!(!is_address(t), "{t}"); + } + } + #[test] fn copy_exports_svg_that_round_trips_through_paste() { let mut s = session(); diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 7fdedc4a..95ee15ee 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -30,6 +30,15 @@ pub const EMF: &str = "image/emf"; /// What Paste reads from other apps, best first: vector art before text, text before bitmaps /// (word processors offer a picture of copied text too). pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP]; +/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the +/// image's address as text next to the picture: then the picture is what Paste takes (#597). An +/// address alone pastes as text, and nothing is fetched. +pub fn is_address(text: &str) -> bool { + const SCHEMES: [&str; 6] = ["http://", "https://", "file://", "ftp://", "data:", "blob:"]; + let t = text.trim(); + !t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len()) +} + /// How much of a copied file [`file_flavour`] needs to tell what it is. pub const FILE_HEAD: usize = 4096; diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index aefd3b86..3d150748 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -8,7 +8,7 @@ //! reads from a paste itself ([`VectorcraftApp::paste_from_host`]). use serde_json::{Value, json}; -use vectorcraft_engine::cmd::clipboard::{EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg}; +use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, is_address, looks_like_svg}; use crate::VectorcraftApp; @@ -90,6 +90,8 @@ impl VectorcraftApp { return Ok(()); } let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) }; + // Text that is only an address goes with a browser's copied picture: the picture. + let f = if f.mime == TEXT && is_address(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f }; import_command(&f, center) } // Text only: SVG markup is art, other text is left alone. diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs index 3b793843..72d1ce6b 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -167,6 +167,22 @@ fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() { assert_eq!(count(&app), before); } +/// A browser's Copy Image: the picture with its address as text (and HTML, which Paste doesn't +/// read). The picture is pasted; an address alone pastes as text, nothing is fetched (#597). +#[test] +fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() { + let (mut app, board) = app(); + let url = b"https://example.com/images/cat.png".to_vec(); + copy_elsewhere(&board, vec![(TEXT, url.clone()), ("text/html", b"".to_vec()), (PNG, blue_png())]); + run(&mut app, "edit.paste", json!({})); + let NodeKind::Image(im) = &pasted(&app)[0].kind else { panic!("not an image: {:?}", pasted(&app)[0].kind) }; + assert!(im.link.is_none() && (im.width, im.height) == (4, 2)); + copy_elsewhere(&board, vec![(TEXT, url)]); + run(&mut app, "edit.paste", json!({})); + let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") }; + assert_eq!(t.plain_text(), "https://example.com/images/cat.png"); +} + #[test] fn a_pdf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { let (mut app, board) = app(); From 2497713779c2499c073189b7edba348b6a004c17 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:31:43 +0700 Subject: [PATCH 45/74] Fix #598: the Free Transform widget While the Free Transform tool (E) is active, a small box at the canvas's top left holds Constrain over the tool's three modes: Free Transform, Perspective Distort and Free Distort. Before, the modes were reachable only by modifier keys or `tool.setOption`. - The buttons set the tool's `constrain` and `mode` options through `tool.setOption`, as agents do. Constrain acts as Shift held and is greyed in the distort modes, where it does nothing. The modifier keys keep working while dragging. - Theme tokens; Constrain shows the link icon, Free Transform the tool's own icon, the distort modes are drawn in code. Tooltips translated (cs, es, it, ja, pt-br, ru, zh-hans, zh-hant). - `ui.inspect` reports where it shows (`freeTransformWidget`). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/free_transform.rs | 202 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 1 + docs/control-protocol.md | 9 +- 14 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/free_transform.rs diff --git a/ROADMAP.md b/ROADMAP.md index a8bc032f..9aff4e90 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); 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), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 588924fa..34453577 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -384,6 +384,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { task_bar(app, ui, &xf); } + if app.ui.screen_mode < 3 { + crate::free_transform::show(app, ui, xf.rect); + } // Cursor. if resp.hovered() { let m = ui.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43977320..724f111e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -84,6 +84,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "pinned": app.ui.task_bar_place.pinned, "rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), }, + // The Free Transform tool's widget, where it shows. + "freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "window": [r.width(), r.height()], diff --git a/crates/ui-egui/src/free_transform.rs b/crates/ui-egui/src/free_transform.rs new file mode 100644 index 00000000..56b3eb24 --- /dev/null +++ b/crates/ui-egui/src/free_transform.rs @@ -0,0 +1,202 @@ +//! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box +//! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and +//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free +//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents +//! do; the modifier keys keep working while dragging. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +/// The tool the widget belongs to. +const TOOL: &str = "freeTransform"; +/// A button's side. +const BUTTON: f32 = 28.0; + +/// What a button shows. +#[derive(Clone, Copy)] +enum Glyph { + /// An icon of the set. + Icon(&'static str), + /// A box narrower at the top than at the bottom, its corners marked. + Perspective, + /// A box with corners pulled every which way, its corners marked. + Distort, +} + +/// The tool's modes as its `mode` option names them, with their buttons. +fn modes() -> [(&'static str, Glyph, &'static str); 3] { + [ + ("free", Glyph::Icon("dc-free-transform"), tl!("Free Transform")), + ("perspective", Glyph::Perspective, tl!("Perspective Distort")), + ("distort", Glyph::Distort, tl!("Free Distort")), + ] +} + +/// Where the widget was last drawn (none while it isn't shown). +pub(crate) fn rect(ctx: &egui::Context) -> Option { + let id = egui::Id::new(TOOL); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// Show the widget in `canvas` (the canvas's area inside the rulers) while the Free Transform +/// tool is active. +pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) { + if app.session.tool_id() != TOOL { + return; + } + let t = Tokens::get(ui.ctx()); + let opts = app.session.tool_options(); + let mode = opts["mode"].as_str().unwrap_or("free").to_string(); + let constrain = opts["constrain"].as_bool().unwrap_or(false); + let mut set: Option<(&str, Value)> = None; + egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.button_border)) + .corner_radius(CornerRadius::same(4)) + .inner_margin(egui::Margin::same(3)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) }) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + // Constrain shapes only what the Free Transform mode does. + if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") { + set = Some(("constrain", json!(!constrain))); + } + let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover()); + ui.painter().hline(line.x_range().shrink(4.0), line.center().y, Stroke::new(1.0, t.button_border)); + for (name, glyph, tip) in modes() { + if button(ui, glyph, tip, mode == name, true) && mode != name { + set = Some(("mode", json!(name))); + } + } + }); + }); + if let Some((key, value)) = set + && let Err(e) = app.run("tool.setOption", json!({ "key": key, "value": value })) + { + app.status(e); + } +} + +/// A square button showing `glyph`, highlighted when `on`, greyed and inert unless `enabled`. +/// Returns whether it was clicked. +fn button(ui: &mut Ui, glyph: Glyph, tip: &str, on: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, BUTTON), if enabled { Sense::click() } else { Sense::hover() }); + let bg = if on && enabled { + t.tool_active + } else if enabled && resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let color = match (enabled, on) { + (false, _) => t.text_dim, + (true, true) => t.text, + (true, false) => t.icon, + }; + paint(ui, glyph, rect.shrink(6.0), color); + resp.on_hover_text(tip).clicked() && enabled +} + +/// Draw `glyph` in `r`. +fn paint(ui: &Ui, glyph: Glyph, r: Rect, color: Color32) { + let at = |x: f32, y: f32| pos2(r.left() + x * r.width(), r.top() + y * r.height()); + let quad: [Pos2; 4] = match glyph { + Glyph::Icon(name) => return crate::icons::paint(ui, name, r, color), + Glyph::Perspective => [at(0.3, 0.12), at(0.7, 0.12), at(0.95, 0.88), at(0.05, 0.88)], + Glyph::Distort => [at(0.08, 0.2), at(0.78, 0.06), at(0.94, 0.9), at(0.2, 0.76)], + }; + let (p, fill) = (ui.painter(), Tokens::get(ui.ctx()).panel); + p.add(egui::Shape::closed_line(quad.to_vec(), Stroke::new(1.3, color))); + for c in quad { + let mark = Rect::from_center_size(c, vec2(3.5, 3.5)); + p.rect_filled(mark, 0.0, fill); + p.rect_stroke(mark, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + use vectorcraft_geom::Point; + + use super::*; + use crate::canvas::Xf; + + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| crate::canvas::show(app, ui)).textures_delta.clear(); + } + + /// A press and release at `at`, then a frame to settle. + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); + frame(app, ctx, vec![button(false)]); + frame(app, ctx, vec![]); + } + + fn options(app: &VectorcraftApp) -> (String, bool) { + let o = app.session.tool_options(); + (o["mode"].as_str().unwrap_or_default().to_string(), o["constrain"].as_bool().unwrap_or_default()) + } + + /// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain + /// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone. + #[test] + fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + app.select_tool("selection"); + frame(&mut app, &ctx, vec![]); + assert!(rect(&ctx).is_none(), "not with the Selection tool"); + app.select_tool(TOOL); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let w = rect(&ctx).expect("the widget shows"); + let canvas = app.canvas_rect.unwrap(); + assert!(canvas.contains(w.min) && w.left() - canvas.left() < 20.0 && w.top() - canvas.top() < 20.0, "{w:?} in {canvas:?}"); + // Constrain, a rule, then Free Transform, Perspective Distort and Free Distort. + let at = |i: usize| { + let rule = if i > 0 { 3.0 + 2.0 } else { 0.0 }; + pos2(w.center().x, w.top() + 4.0 + BUTTON / 2.0 + i as f32 * (BUTTON + 2.0) + rule) + }; + assert_eq!(options(&app), ("free".into(), false)); + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("free".into(), true), "Constrain on"); + click(&mut app, &ctx, at(3)); + assert_eq!(options(&app), ("distort".into(), true)); + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort"); + click(&mut app, &ctx, at(2)); + assert_eq!(options(&app).0, "perspective"); + click(&mut app, &ctx, at(3)); + // Free Distort: the top-left corner dragged alone. + let xf = Xf::new(canvas, app.view().unwrap()); + let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0))); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false)]); + frame(&mut app, &ctx, vec![]); + let st = app.session.active().unwrap(); + let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let pts: Vec = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect(); + let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5); + assert!( + near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)), + "{pts:?}" + ); + assert_eq!(app.session.tool_id(), TOOL); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 82c42bc8..7b9207c3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -635,3 +635,6 @@ Appearance of Black Vzhled černé Devices Zařízení A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm + Constrain Omezit + Free Transform Volná transformace + Perspective Distort Perspektivní deformace diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52049bd1..d0390099 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3599,3 +3599,6 @@ axis Both Ambos Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo. VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo. A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él + Constrain Restringir + Free Transform Transformación libre + Perspective Distort Distorsión de perspectiva diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c02a2915..007c7633 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3599,3 +3599,6 @@ axis Both Entrambi Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto. VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia. A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì + Constrain Vincola + Free Transform Trasformazione libera + Perspective Distort Distorsione prospettica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffe95c7b..656c0eb5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3061,3 +3061,6 @@ theme Appearance 外観 Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。 VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。 A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください + Constrain 制限 + Free Transform 自由変形 + Perspective Distort 遠近変形 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e98ec47a..4ecd356e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2649,3 +2649,6 @@ Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo. VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda. A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro + Constrain Restringir + Free Transform Transformação Livre + Perspective Distort Distorção de Perspectiva diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fbeac868..402d69bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3597,3 +3597,6 @@ axis Both Оба 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 Открыто окно выбора файла: сначала сделайте выбор в нём + Constrain Ограничить + Free Transform Свободное трансформирование + Perspective Distort Искажение перспективы diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f9dbec4..0bdc7380 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3316,3 +3316,5 @@ axis Both 两者 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 文件对话框已打开:请先在其中选择 + Constrain 约束 + Perspective Distort 透视扭曲 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d1d78f38..34d5395b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3091,3 +3091,6 @@ axis Both 兩者 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 檔案對話方塊已開啟:請先在其中選擇 + Constrain 強制 + Free Transform 任意變形 + Perspective Distort 透視扭曲 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5a99a00..4e301b85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod dock; pub mod find_font; pub mod floating; pub mod font_menu; +mod free_transform; pub mod graphics; pub mod i18n; pub mod icon_data; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebf984d0..a5414b52 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | @@ -214,6 +214,13 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the selection. +The Free Transform widget: while the Free Transform tool (E) is active, a small box at the canvas's top left holds +Constrain over the tool's three modes, Free Transform, Perspective Distort and Free Distort. The buttons set the tool's +options, as `tool.setOption {key: "mode", value: "free"|"perspective"|"distort"}` and `{key: "constrain", value}` do: +Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) and does nothing in the distort modes, +where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in +perspective, Cmd on a side shears). + Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own; From aa125bfff10d966694b66ac80852521c907f9333 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:39:47 +0700 Subject: [PATCH 46/74] Fix #585: a two-finger tap undoes, a three-finger tap redoes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › Devices › Enable Touch Gestures (on by default) was stored but nothing read it: no touch gesture was implemented. - `touch::Taps` follows the fingers egui reports (winit reads them from the system: WM_POINTER on Windows): fingers down together and lifted within half a second, none moved more than 16 points, is a tap. Two fingers undo, three redo, with the preference on. - egui makes the first finger the pointer too, so it has pressed on the canvas by the time a second lands: that press is dropped then (its tool interaction rolled back), and the rest of the gesture presses nothing. The tap so undoes what came before it, not the first finger's mark. - A pen is one contact, so pen strokes and taps are unaffected; pinch zoom is unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 85 ++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/touch.rs | 131 +++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/touch.rs diff --git a/ROADMAP.md b/ROADMAP.md index 025d72d8..f9856fa8 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); 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), touch gestures: a two-finger tap undoes and a three-finger tap redoes (#585); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5bf2405..0ce3f9c1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -79,6 +79,8 @@ enum Drag { /// A Selection tool move dragged off the canvas: the panels get the art /// ([`widgets::PanelDrag::Art`]). Art, + /// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing. + Gesture, } fn drag_id() -> egui::Id { @@ -474,6 +476,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); app.hover_doc = hover.map(|p| xf.to_doc(p)); let view = app.view_info(); + touch_gestures(app, ui, rect, view); let drag: Option = ui.data(|d| d.get_temp(drag_id())); // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows // without moving the mouse (Alt switches the Shape Builder to erase mode). @@ -565,7 +568,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: vm.rotation = vectorcraft_geom::normalize_deg(deg); } } - Drag::ZoomBox { .. } | Drag::Art => {} + Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {} Drag::Tool if drag_art_out(app, ui, resp, p, view) => { ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); } @@ -609,7 +612,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } - Drag::Art => {} + Drag::Art | Drag::Gesture => {} Drag::Pan { .. } | Drag::RotateView { .. } => {} } } @@ -688,6 +691,50 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn (zoom, scroll) } +/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second +/// finger drops the press the first one began (its tool interaction rolled back; the rest of the +/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. +fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) { + if !app.session.prefs.touch_gestures { + return; + } + let (touches, now): (Vec<_>, f64) = ui.input(|i| { + let t = i.events.iter().filter_map(|e| match e { + egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)), + _ => None, + }); + (t.collect(), i.time) + }); + if touches.is_empty() { + return; + } + let key = egui::Id::new("canvas-touch"); + let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default(); + let mut tap = None; + for (id, phase, pos) in touches { + // A gesture begins on the canvas. + if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) { + continue; + } + match taps.feed(id, phase, pos, now) { + Some(crate::touch::Touch::Fingers) => { + if ui.data(|d| d.get_temp::(drag_id())) == Some(Drag::Tool) { + // Nothing it began is kept; the tool's mouse-up returns it to rest. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); + } + Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), + None => {} + } + } + ui.data_mut(|d| d.insert_temp(key, taps)); + if let Some(cmd) = tap { + crate::menus::invoke(app, cmd, serde_json::json!({})); + } +} + /// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of /// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it /// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it). @@ -1865,6 +1912,40 @@ mod tests { out.textures_delta.clear(); } + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the + /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. + #[test] + fn two_finger_tap_undoes_and_three_finger_tap_redoes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len(); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a + // little (as a rectangle drag would begin). + let tap = |app: &mut VectorcraftApp, fingers: u64| { + let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect()); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect()); + frame(app, &ctx, vec![]); + }; + tap(&mut app, 2); + assert_eq!(count(&app), 0, "the rectangle undone, none drawn"); + tap(&mut app, 3); + assert_eq!(count(&app), 1, "redone"); + app.session.prefs.touch_gestures = false; + tap(&mut app, 2); + assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone"); + } + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own /// labels are interface text. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5a99a00..d7f9fad0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,7 @@ pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; +mod touch; mod ui_fonts; pub mod unsaved; pub mod widgets; diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs new file mode 100644 index 00000000..1f5e9c99 --- /dev/null +++ b/crates/ui-egui/src/touch.rs @@ -0,0 +1,131 @@ +//! Touch gestures on the canvas (Preferences › Devices › Enable Touch Gestures, #585): a tap with +//! two fingers undoes, one with three redoes. +//! +//! egui hands every finger's contact over as [`egui::Event::Touch`] (winit reads them from the +//! system: `WM_POINTER` on Windows), and makes the first finger the mouse pointer too. So the first +//! finger has pressed on the canvas by the time a second lands: that press is dropped then +//! ([`Taps::feed`] says [`Touch::Fingers`]), and the tap undoes what came before it. + +use egui::{Pos2, TouchId, TouchPhase}; + +/// How long the fingers of a tap may stay down, in seconds. +const TAP_TIME: f64 = 0.5; +/// How far (points) a finger of a tap may move: further, it is a pinch or a pan. +const TAP_SLOP: f32 = 16.0; + +/// What a touch event made of the gesture. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Touch { + /// A second finger landed: the gesture isn't the first finger's press. + Fingers, + /// All fingers lifted from a tap of this many fingers (two or more). + Tap(usize), +} + +/// The fingers down and how the gesture they make has gone so far. +#[derive(Clone, Debug, Default)] +pub(crate) struct Taps { + /// Each finger down and where it landed. + down: Vec<(TouchId, Pos2)>, + /// The most fingers down at once in this gesture. + most: usize, + /// When its first finger landed. + start: f64, + /// A finger moved too far or stayed too long: not a tap. + spoilt: bool, +} + +impl Taps { + /// Follow a finger's `phase` at `pos` (touch `id`) at time `now` (seconds). + pub(crate) fn feed(&mut self, id: TouchId, phase: TouchPhase, pos: Pos2, now: f64) -> Option { + match phase { + TouchPhase::Start => { + if self.down.is_empty() { + *self = Self { start: now, ..Self::default() }; + } + if !self.down.iter().any(|(d, _)| *d == id) { + self.down.push((id, pos)); + } + let before = self.most; + self.most = self.most.max(self.down.len()); + (before < 2 && self.most >= 2).then_some(Touch::Fingers) + } + TouchPhase::Move => { + if self.down.iter().any(|(d, at)| *d == id && at.distance(pos) > TAP_SLOP) { + self.spoilt = true; + } + None + } + TouchPhase::End | TouchPhase::Cancel => { + let known = self.down.len(); + self.down.retain(|(d, _)| *d != id); + if self.down.len() == known { + return None; + } + self.spoilt |= phase == TouchPhase::Cancel || now - self.start > TAP_TIME; + (self.down.is_empty() && !self.spoilt && self.most >= 2).then_some(Touch::Tap(self.most)) + } + } + } + + /// A finger is down. + pub(crate) fn is_down(&self) -> bool { + !self.down.is_empty() + } +} + +/// The command a tap of `fingers` runs: two undo, three redo. +pub(crate) fn tap_command(fingers: usize) -> Option<&'static str> { + match fingers { + 2 => Some("edit.undo"), + 3 => Some("edit.redo"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(n: u64) -> TouchId { + TouchId(n) + } + + #[test] + fn two_and_three_finger_taps() { + let mut t = Taps::default(); + let p = Pos2::new(100.0, 100.0); + assert_eq!(t.feed(id(1), TouchPhase::Start, p, 0.0), None); + assert_eq!(t.feed(id(2), TouchPhase::Start, p + egui::vec2(60.0, 0.0), 0.05), Some(Touch::Fingers)); + assert!(t.is_down()); + assert_eq!(t.feed(id(1), TouchPhase::Move, p + egui::vec2(3.0, 2.0), 0.1), None); + assert_eq!(t.feed(id(1), TouchPhase::End, p, 0.15), None, "one finger still down"); + assert_eq!(t.feed(id(2), TouchPhase::End, p, 0.2), Some(Touch::Tap(2))); + assert!(!t.is_down()); + // Three fingers. + for (n, at) in [(4, 0.0), (5, 0.02), (6, 0.04)] { + t.feed(id(n), TouchPhase::Start, p, 1.0 + at); + } + let ends: Vec<_> = [4, 5, 6].into_iter().map(|n| t.feed(id(n), TouchPhase::End, p, 1.2)).collect(); + assert_eq!(ends, [None, None, Some(Touch::Tap(3))]); + assert_eq!((tap_command(2), tap_command(3), tap_command(1)), (Some("edit.undo"), Some("edit.redo"), None)); + } + + #[test] + fn a_pinch_a_long_press_a_cancel_or_one_finger_is_no_tap() { + let p = Pos2::new(100.0, 100.0); + let gesture = |moved: f32, held: f64, last: TouchPhase, fingers: u64| { + let mut t = Taps::default(); + for n in 0..fingers { + t.feed(id(n), TouchPhase::Start, p, 0.0); + } + t.feed(id(0), TouchPhase::Move, p + egui::vec2(moved, 0.0), held / 2.0); + (0..fingers).filter_map(|n| t.feed(id(n), if n + 1 == fingers { last } else { TouchPhase::End }, p, held)).last() + }; + assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 2), Some(Touch::Tap(2))); + assert_eq!(gesture(40.0, 0.2, TouchPhase::End, 2), None, "a pinch"); + assert_eq!(gesture(0.0, 0.9, TouchPhase::End, 2), None, "held too long"); + assert_eq!(gesture(0.0, 0.2, TouchPhase::Cancel, 2), None, "cancelled"); + assert_eq!(gesture(0.0, 0.2, TouchPhase::End, 1), None, "a pen or one finger"); + } +} From aa5e8f46e96e53a166720dfafd0e4be21bfbaa85 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:43:13 +0700 Subject: [PATCH 47/74] Part of #579: vectorcraft-cli lists the fonts font services load too The DirectWrite font lister (#594) was installed in the desktop app only: `vectorcraft-cli` (convert, run, MCP) didn't see fonts that font services such as Adobe Fonts load on Windows, so its exports set such type in a fallback font. - The CLI shares the app's `system_fonts.rs` and installs it first thing on Windows (not the win7 build). The text crate stays free of unsafe code; the CLI denies unsafe code instead of forbidding it, so the shared module's own allowance applies. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 +++ ROADMAP.md | 2 +- apps/vectorcraft-cli/Cargo.toml | 7 +++++++ apps/vectorcraft-cli/src/main.rs | 11 ++++++++++- apps/vectorcraft/src/system_fonts.rs | 3 ++- docs/mcp.md | 4 ++-- 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7334a36d..deab7703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4352,6 +4352,7 @@ dependencies = [ name = "vectorcraft-cli" version = "0.7.0" dependencies = [ + "log", "serde_json", "vectorcraft-color", "vectorcraft-doc", @@ -4362,6 +4363,8 @@ dependencies = [ "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", + "vectorcraft-text", + "windows", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 025d72d8..9b974a51 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); 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 same DirectWrite fonts in `vectorcraft-cli` and its MCP server (#579); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 0fcd8674..2619644b 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -22,7 +22,14 @@ vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-mcp = { workspace = true } vectorcraft-render = { workspace = true } +vectorcraft-text = { workspace = true } serde_json = { workspace = true } +log = { workspace = true } + +# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that +# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0. +[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 38d917fa..3e3e99ca 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -9,7 +9,9 @@ //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] //! ``` -#![forbid(unsafe_code)] +// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows +// it for its COM calls, and nothing else may. +#![deny(unsafe_code)] use std::io::Write; use std::process::ExitCode; @@ -35,6 +37,10 @@ macro_rules! out { } mod perf; +/// 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"] +mod system_fonts; use serde_json::{Value, json}; use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; @@ -93,6 +99,9 @@ fn usage() -> String { } fn main() -> ExitCode { + // Before any font lookup: the fonts font services load (#579). + #[cfg(all(windows, not(target_vendor = "win7")))] + system_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/src/system_fonts.rs b/apps/vectorcraft/src/system_fonts.rs index fc7eb38d..6752aef6 100644 --- a/apps/vectorcraft/src/system_fonts.rs +++ b/apps/vectorcraft/src/system_fonts.rs @@ -1,5 +1,6 @@ //! Windows: the font files in DirectWrite's system font collection, scanned with the font folders -//! (`vectorcraft_text::set_platform_font_files`). +//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports, +//! MCP) both install it; the CLI shares this file. //! //! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their //! own folders, without installing them: the font folders and the registry don't list them. Since diff --git a/docs/mcp.md b/docs/mcp.md index bcece579..8f4dbeac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2382,8 +2382,8 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web): 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 the fonts in DirectWrite's system -font collection, such as those Adobe Fonts activates while Creative Cloud runs; macOS: `/System/Library/Fonts`, `/Library/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`, `/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 From b843fe8af3f3f815d78eabf51abdd1c7c8bf79c6 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:03:49 -0700 Subject: [PATCH 48/74] M7: inline symbols in text Document symbols placed inline in a run of type (beyond Illustrator; like InDesign inline anchored objects): TextRun.inline holds the symbol, scale and shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on the cap height. text.insertInline and Type > Insert Inline Symbol; editing keeps the graphic whole; canvas, SVG () and PDF draw the art; Create Outlines turns it into a symbol instance. --- ROADMAP.md | 8 +- crates/doc/src/inline.rs | 108 ++++++++ crates/doc/src/lib.rs | 8 +- crates/doc/src/text.rs | 66 ++++- crates/engine/src/cmd/brushsym.rs | 4 +- crates/engine/src/cmd/inline.rs | 109 ++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/textedit.rs | 2 +- crates/engine/src/cmd/threads.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 13 +- crates/engine/src/cmd/typemenu.rs | 29 +- crates/engine/src/lib.rs | 6 + crates/engine/src/tests_inline.rs | 207 ++++++++++++++ crates/engine/tests/command_sweep.rs | 78 ++++++ crates/engine/tests/inline_text.rs | 139 ++++++++++ crates/format/tests/native_v3.rs | 23 ++ crates/pdf/src/export.rs | 26 ++ crates/pdf/src/import_lines.rs | 2 +- crates/pdf/src/import_text.rs | 10 +- crates/pdf/src/tests_realtext.rs | 6 +- crates/render/src/lib.rs | 45 +++- crates/svg/src/export.rs | 43 ++- crates/svg/src/import/text.rs | 2 +- crates/svg/tests/encoding_profile_fonts.rs | 2 +- crates/svg/tests/options.rs | 4 +- crates/svg/tests/text_layout.rs | 2 +- crates/svg/tests/text_stroke.rs | 2 +- crates/testkit/src/fixtures.rs | 6 + crates/text/src/edit.rs | 32 ++- crates/text/src/layout.rs | 46 +++- crates/text/src/lib.rs | 34 ++- crates/text/src/shape.rs | 122 +++++++-- crates/text/src/tests.rs | 18 +- crates/text/src/tests_bidi.rs | 18 +- crates/text/src/tests_fit.rs | 2 +- crates/text/src/tests_inline.rs | 298 +++++++++++++++++++++ crates/text/src/tests_typo.rs | 4 +- crates/text/src/thread.rs | 10 +- crates/tools/src/text_tests.rs | 2 +- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.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/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus.rs | 1 + docs/mcp.md | 25 ++ 49 files changed, 1474 insertions(+), 100 deletions(-) create mode 100644 crates/doc/src/inline.rs create mode 100644 crates/engine/src/cmd/inline.rs create mode 100644 crates/engine/src/tests_inline.rs create mode 100644 crates/engine/tests/inline_text.rs create mode 100644 crates/text/src/tests_inline.rs diff --git a/ROADMAP.md b/ROADMAP.md index e9c71e44..ec28727a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,7 +34,7 @@ since 2026-10-01. | Dimension | Estimate | Evidence and what's missing | |---|---|---| | **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | -| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) | +| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) | | **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | @@ -140,7 +140,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. 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:** 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). - **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -177,7 +177,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -213,7 +213,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 20–28 | +| 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is 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 presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs new file mode 100644 index 00000000..3ab3eb65 --- /dev/null +++ b/crates/doc/src/inline.rs @@ -0,0 +1,108 @@ +//! Inline graphics in text ([`crate::TextRun::inline`]): resolving each inline symbol's art bounds +//! from the document's symbols, which the text layout needs to size and place it. + +use std::borrow::Cow; + +use vectorcraft_geom::{Affine, Rect}; + +use crate::{Document, NodeId, NodeKind, TextObject}; + +/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). +pub const SYMBOL_SIZES: &str = "symbolSizes"; +/// Half the side of the square symbol art is normalised to. +pub const SYMBOL_HALF: f64 = 10.0; + +/// Usable art bounds: finite, non-degenerate. +fn usable(r: Option) -> Option { + r.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() >= 0.0 && r.height() > 1e-9) +} + +impl Document { + /// Symbol space → the symbol's art at its natural size. Symbols made in the app keep their + /// art in a 20 × 20 square around the origin and their size as drawn in the document's + /// `symbolSizes` (an instance is placed with that scale); other symbols are as they are. + pub fn symbol_natural_xf(&self, name: &str) -> Affine { + let size = self.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(name)).and_then(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))); + match size { + Some((w, h)) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => { + Affine::scale_non_uniform(w / (2.0 * SYMBOL_HALF), h / (2.0 * SYMBOL_HALF)) + } + _ => Affine::IDENTITY, + } + } + + /// Visual bounds of the art of the symbol named `name` at its natural size (see + /// [`Self::symbol_natural_xf`]), if it exists and has a usable extent. + pub fn symbol_art_bounds(&self, name: &str) -> Option { + let s = self.symbols.iter().find(|s| s.name == name)?; + let b = usable(s.art.visual_bounds().or_else(|| s.art.geometric_bounds()))?; + usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) + } + + /// Does `t` have inline graphics whose resolved bounds differ from the document's symbols? + fn inline_stale(&self, t: &TextObject) -> bool { + t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol)) + } + + /// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed + /// when nothing changes). Renderers and exporters lay text out through this, so they never + /// depend on the cached bounds being current. + pub fn inline_resolved<'a>(&self, t: &'a TextObject) -> Cow<'a, TextObject> { + if !self.inline_stale(t) { + return Cow::Borrowed(t); + } + let mut t = t.clone(); + self.resolve_text_inline(&mut t); + Cow::Owned(t) + } + + /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's + /// cached layout bounds are then cleared). + pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool { + let mut changed = false; + for r in &mut t.runs { + if let Some(a) = &mut r.inline { + let b = self.symbol_art_bounds(&a.symbol); + if a.bounds != b { + a.bounds = b; + changed = true; + } + } + } + if changed { + t.cached_bounds = None; + } + changed + } + + /// Resolve the inline graphics of every text object in the layers (after opening a document + /// or changing its symbols). Returns the ids of the texts that changed: their cached layout + /// bounds were cleared and need recomputing. + pub fn resolve_inline_art(&mut self) -> Vec { + let mut stale = vec![]; + self.walk(|n| { + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().any(|r| r.inline.is_some()) + && self.inline_stale(t) + { + stale.push(n.id); + } + }); + let mut out = vec![]; + for id in stale { + let Some(mut t) = self.node(id).and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.clone()), + _ => None, + }) else { + continue; + }; + if self.resolve_text_inline(&mut t) + && let Some(n) = self.node_mut(id) + { + n.kind = NodeKind::Text(t); + out.push(id); + } + } + out + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 480a40d3..bd627930 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,8 @@ pub mod corners; pub mod graph; pub mod hit; pub mod inks; +mod inline; +pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub mod links; pub mod live; pub mod marks; @@ -83,9 +85,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, - ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, - WrapShape, + AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, LeadingModel, Mojikumi, + ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, + VerticalAlign, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fa19cb5a..7caa1dce 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -758,6 +758,70 @@ pub struct TextStyleDef { pub struct TextRun { pub text: String, pub style: CharStyle, + /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows + /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets + /// the size and tracking; fills and strokes don't apply to the art. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inline: Option, +} + +impl TextRun { + /// A plain text run. + pub fn new(text: impl Into, style: CharStyle) -> Self { + Self { text: text.into(), style, inline: None } + } + /// An inline graphic run showing `art`. + pub fn inline(art: InlineArt, style: CharStyle) -> Self { + Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } + } +} + +/// The object replacement character: the plain text of an inline graphic run. +pub const INLINE_CHAR: char = '\u{FFFC}'; + +/// Largest inline graphic scale (× the run's size) accepted from input. +pub const INLINE_MAX_SCALE: f64 = 100.0; + +/// A document symbol placed inline in text ([`TextRun::inline`]). +/// +/// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's +/// font size, its left edge at the pen position, and its vertical centre on the middle of the +/// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap +/// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at +/// the default scale, stays inside the font's ascent and descent so the leading doesn't change. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineArt { + /// Name of the document symbol ([`crate::Symbol::name`]). + pub symbol: String, + /// Art height as a multiple of the run's font size. + #[serde(default = "one")] + pub scale: f64, + /// Extra raise in points (positive = up). + #[serde(default)] + pub baseline_shift: f64, + /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by + /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the + /// layout then reserves a one-em square and nothing is drawn. + #[serde(skip)] + pub bounds: Option, +} + +fn one() -> f64 { + 1.0 +} + +impl InlineArt { + pub fn new(symbol: impl Into) -> Self { + Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } + } + /// `scale`, made finite and positive (untrusted input). + pub fn safe_scale(&self) -> f64 { + if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } + } + /// `baseline_shift`, made finite (untrusted input). + pub fn safe_shift(&self) -> f64 { + if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -863,7 +927,7 @@ impl TextObject { vertical: false, kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), - runs: vec![TextRun { text: text.into(), style }], + runs: vec![TextRun::new(text, style)], para: ParaStyle::default(), paras: Vec::new(), area: AreaOptions::default(), diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index db379953..720ab8ae 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -18,10 +18,10 @@ use super::edit::selected_roots; use super::*; use crate::EngineError; -const SIZES_KEY: &str = "symbolSizes"; +const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. -const HALF: f64 = 10.0; +const HALF: f64 = vectorcraft_doc::SYMBOL_HALF; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs new file mode 100644 index 00000000..7c6fa20e --- /dev/null +++ b/crates/engine/src/cmd/inline.rs @@ -0,0 +1,109 @@ +//! Inline graphics in text: document symbols placed in a run of text, flowing like a glyph +//! (beyond Illustrator; InDesign's inline anchored objects). See [`vectorcraft_doc::InlineArt`]. + +use serde_json::{Value, json}; +use vectorcraft_doc::text::INLINE_MAX_SCALE; +use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun}; +use vectorcraft_text::edit; + +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![cmd!( + "text.insertInline", + "Insert Inline Symbol", + ["Type"], + None, + "{id?: text id (default: the text the Type tool edits), at?: byte offset (default: the Type tool's selection, which it replaces; clamped to the text), symbol?: symbol name (default: the Symbols panel's current symbol), scale?: art height ÷ font size = 1 (0 < scale ≤ 100), shift?: pt raise = 0} place a document symbol inline in the text as one character (U+FFFC) that flows like a glyph: its art is scaled to `scale` × the font size tall, its vertical centre on the middle of the cap height raised by `shift`. Typing, Backspace and Delete over it remove it whole → {id, caret}", + has_doc, + insert_inline + )] +} + +/// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols, +/// resolve the inline art of every text again (only texts whose art changed are touched). +pub(crate) fn refresh(before: &Document, d: &mut Document) { + if before.symbols.is_empty() && d.symbols.is_empty() { + return; + } + for id in d.resolve_inline_art() { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + refresh_bounds(t); + } + } +} + +fn insert_inline(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.insertInline"; + let tool = s.tool_options(); + let editing = tool.get("editing").and_then(Value::as_u64).map(NodeId); + let id = match p.get("id") { + None | Some(Value::Null) => editing.ok_or_else(|| bad(C, "give `id` (no text is being edited)"))?, + Some(_) => id_param(p, "id").ok_or_else(|| bad(C, "`id` must be a node id"))?, + }; + let doc = &s.doc()?.doc; + let NodeKind::Text(t) = &doc.node(id).ok_or(EngineError::NoNode(id))?.kind else { + return Err(bad(C, format!("node {} is not text", id.0))); + }; + let len = edit::runs_len(&t.runs); + let from_tool = editing == Some(id); + let (a, b) = match p.get("at") { + None | Some(Value::Null) if from_tool => { + let g = |k: &str| tool.get(k).and_then(Value::as_u64).map_or(len, |v| (v as usize).min(len)); + let (x, y) = (g("start"), g("end")); + (x.min(y), x.max(y)) + } + None | Some(Value::Null) => (len, len), + Some(v) => { + let at = v.as_u64().ok_or_else(|| bad(C, "`at` must be a byte offset"))?.min(len as u64) as usize; + (at, at) + } + }; + let symbol = match str_param(p, "symbol") { + Some(n) if doc.symbols.iter().any(|s| s.name == n) => n.to_string(), + Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))), + None if p.get("symbol").is_some_and(|v| !v.is_null()) => return Err(bad(C, "`symbol` must be a symbol name")), + None => current_symbol(doc).ok_or_else(|| EngineError::Other("the document has no symbols".into()))?, + }; + let scale = match p.get("scale") { + None | Some(Value::Null) => 1.0, + Some(v) => v + .as_f64() + .filter(|k| k.is_finite() && *k > 0.0 && *k <= INLINE_MAX_SCALE) + .ok_or_else(|| bad(C, "`scale` must be a number in (0, 100]"))?, + }; + let shift = match p.get("shift") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|k| k.is_finite() && k.abs() <= 1e5).ok_or_else(|| bad(C, "`shift` must be a number of points"))?, + }; + // A typing session ends first: the insertion is a step of its own. + let typing = tool.get("typing").and_then(Value::as_bool) == Some(true); + if typing && from_tool { + s.set_tool_option("commitTyping", &Value::Bool(true)); + s.commit_interaction()?; + } + let caret = s.edit("Insert Inline Symbol", |d, _| { + let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) }; + art.bounds = d.symbol_art_bounds(&symbol); + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let style = edit::insertion_style(&t.runs, a, b); + let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); + refresh_bounds(t); + Ok(caret) + })?; + if from_tool { + s.set_tool_option("select", &json!({"start": caret, "end": caret})); + } + Ok(json!({"id": id.0, "caret": caret})) +} + +/// The Symbols panel's current symbol, else the first one. +fn current_symbol(d: &Document) -> Option { + d.unknown + .get("currentSymbol") + .and_then(Value::as_str) + .filter(|n| d.symbols.iter().any(|s| s.name == *n)) + .map(str::to_string) + .or_else(|| d.symbols.first().map(|s| s.name.clone())) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 182518bd..f8dca8b6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -29,6 +29,7 @@ pub(crate) mod gradient; pub(crate) mod graph; mod halftone; pub mod help; +pub(crate) mod inline; mod layer; mod layerpanel; pub mod links; @@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(colormgmt::specs()); v.extend(typemenu::specs()); v.extend(textedit::specs()); + v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); v.extend(help::specs()); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index bd95964f..4a839ff2 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -468,7 +468,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false), kind, xf: Affine::IDENTITY, - runs: vec![TextRun { text, style }], + runs: vec![TextRun { text, style, inline: None }], para: super::create::new_type_para(), paras: Vec::new(), area: Default::default(), diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index a6c83626..a70a1223 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -219,7 +219,7 @@ fn release(s: &mut Session, _: &Value) -> Result { let first_para = paras.first().cloned().unwrap_or_default(); for id in &out { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { - t.runs = vec![TextRun { text: String::new(), style: style.clone() }]; + t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }]; t.set_all_paras(first_para.clone()); refresh_bounds(t); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6062781a..4d585209 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -216,7 +216,16 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { let NodeKind::Text(t) = &n.kind else { continue }; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let mut children = vec![]; - for g in &lay.glyphs { + for (gi, g) in lay.glyphs.iter().enumerate() { + // Inline graphics become instances of their symbols, in place. + if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi) + && let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) + { + let id = d.alloc_id(); + let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol); + children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf }))); + continue; + } let path = PathData::from_bezpath(&g.outline).transformed(t.xf); if path.is_empty() { continue; @@ -320,7 +329,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result { let style = t.first_style(); // Every paragraph of the new text takes the first paragraph's attributes. t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text); - t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }]; + t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }]; refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 4db829e0..4a19dad3 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -140,7 +140,8 @@ fn edit_runs(s: &mut Session, label: &str, ids: &[NodeId], f: impl F let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let mut st = S::default(); let mut any = false; - for r in &mut t.runs { + // Inline graphics are art, not characters to rewrite. + for r in t.runs.iter_mut().filter(|r| r.inline.is_none()) { let n = f(&r.text, &mut st); if n != r.text { r.text = n; @@ -323,7 +324,18 @@ fn to_point(s: &mut Session, p: &Value) -> Result { } } else if let Some((ri, bi)) = locate(&t.runs, b) { t.splice_paras(b, b, "\n"); - t.runs[ri].text.insert(bi, '\n'); + // Never inside an inline graphic's run: at the end of the run before it, or a + // run of its own. + match t.runs.get(ri) { + Some(r) if r.inline.is_some() => match ri.checked_sub(1).and_then(|p| t.runs.get_mut(p)) { + Some(prev) if prev.inline.is_none() => prev.text.push('\n'), + _ => { + let style = t.runs.get(ri).map(|r| r.style.clone()).unwrap_or_default(); + t.runs.insert(ri, TextRun::new("\n", style)); + } + }, + _ => t.runs[ri].text.insert(bi, '\n'), + } } } // The point origin sits where the alignment anchors the first line. @@ -481,7 +493,7 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) { } _ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".", }; - t.runs = vec![TextRun { text, style }]; + t.runs = vec![TextRun { text, style, inline: None }]; refresh_bounds(t); } @@ -531,8 +543,13 @@ fn insert_char(s: &mut Session, p: &Value) -> Result { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; match t.runs.last_mut() { - Some(r) => r.text.push_str(&text), - None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }), + Some(r) if r.inline.is_none() => r.text.push_str(&text), + // After an inline graphic: a run of its own, in its style. + Some(r) => { + let style = r.style.clone(); + t.runs.push(TextRun::new(text.clone(), style)); + } + None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default(), inline: None }), } refresh_bounds(t); } @@ -647,7 +664,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { fn strip_formatting(n: &mut vectorcraft_doc::Node) { if let NodeKind::Text(t) = &mut n.kind { let text = t.plain_text(); - t.runs = vec![TextRun { text, style: CharStyle::default() }]; + t.runs = vec![TextRun { text, style: CharStyle::default(), inline: None }]; t.set_all_paras(Default::default()); refresh_bounds(t); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9f1ddc69..15786eb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -908,6 +908,8 @@ impl Session { /// Text layout bounds are a cache (not saved): compute them for a document just read, or /// selection boxes and hit testing would use the rough estimate until each text is edited. fn refresh_text_bounds(doc: &mut Document) { + // Inline graphics in text take their size from their symbols' art (not saved either). + doc.resolve_inline_art(); let mut texts = vec![]; doc.walk(|n| { if matches!(n.kind, NodeKind::Text(_)) { @@ -1103,6 +1105,8 @@ impl Session { Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) { Err(e) => Err(e), Ok(()) => { + // Inline graphics in text follow their symbols. + cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc)); // Text Wrap: area type follows its wrap objects; then threads re-flow. cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); // Opacity-mask editing: the mask follows its art on the editing layer. @@ -1388,6 +1392,8 @@ mod tests_gradpanel; #[cfg(test)] mod tests_halftone; #[cfg(test)] +mod tests_inline; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_knockout; diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests_inline.rs new file mode 100644 index 00000000..71affad3 --- /dev/null +++ b/crates/engine/src/tests_inline.rs @@ -0,0 +1,207 @@ +//! Inline graphics in text: `text.insertInline`, editing over them, undo, following their symbol. + +use serde_json::json; +use vectorcraft_doc::text::INLINE_CHAR; +use vectorcraft_doc::{NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// A symbol `name` made of a circle `d` points across (drawn in code), its instance deleted. +fn circle_symbol(s: &mut Session, name: &str, d: f64) { + s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("symbol.new", &json!({"name": name})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); +} + +fn text(s: &mut Session, t: &str) -> NodeId { + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn inline_count(t: &TextObject) -> usize { + t.runs.iter().filter(|r| r.inline.is_some()).count() +} + +#[test] +fn insert_inline_places_a_symbol_as_one_character() { + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = text(&mut s, "Tap: add"); + let before = obj(&s, id).bounds().unwrap(); + let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1.5, "shift": 1})).unwrap(); + assert_eq!(r, json!({"id": id.0, "caret": INLINE_CHAR.len_utf8()})); + let t = obj(&s, id); + assert_eq!(t.plain_text(), format!("{INLINE_CHAR}Tap: add")); + let art = t.runs[0].inline.as_ref().unwrap(); + assert_eq!((art.symbol.as_str(), art.scale, art.baseline_shift), ("Tap", 1.5, 1.0)); + // Resolved from the symbol (a 10 pt circle), and the text grew by the art (30 pt wide). + let b = art.bounds.unwrap(); + assert!((b.width() - 10.0).abs() < 0.5 && (b.height() - 10.0).abs() < 0.5, "{b:?}"); + let after = t.bounds().unwrap(); + assert!((after.width() - before.width() - 30.0).abs() < 1.0, "{before:?} → {after:?}"); + // It takes the style of the text it sits in. + assert_eq!(t.runs[0].style.size, 20.0); + // text.getRange reports it. + let g = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 3})).unwrap(); + assert_eq!(g["runs"][0]["inline"], json!({"symbol": "Tap", "scale": 1.5, "baseline_shift": 1.0})); + assert_eq!(g["text"], json!(INLINE_CHAR.to_string())); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(inline_count(&obj(&s, id)), 0); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(inline_count(&obj(&s, id)), 1); + // Deleting any part of its character deletes it whole. + s.execute("text.editRange", &json!({"id": id.0, "start": 1, "end": 3})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Tap: add"); + assert_eq!(inline_count(&t), 0); +} + +#[test] +fn insert_inline_rejects_junk() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 8.0); + let id = text(&mut s, "abc"); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + for p in [ + json!({"id": id.0, "symbol": "Nope"}), + json!({"id": id.0, "symbol": 7}), + json!({"id": id.0, "symbol": "Dot", "scale": 0}), + json!({"id": id.0, "symbol": "Dot", "scale": -1}), + json!({"id": id.0, "symbol": "Dot", "scale": 1e9}), + json!({"id": id.0, "symbol": "Dot", "shift": "up"}), + json!({"id": id.0, "symbol": "Dot", "at": -3}), + json!({"id": "x", "symbol": "Dot"}), + json!({"id": rect["id"], "symbol": "Dot"}), + json!({"id": 999999, "symbol": "Dot"}), + json!({"symbol": "Dot"}), + ] { + assert!(s.execute("text.insertInline", &p).is_err(), "{p}"); + } + assert_eq!(inline_count(&obj(&s, id)), 0); + // Offsets past the end are clamped; mid-character offsets land on a boundary. + let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 99, "symbol": "Dot"})).unwrap(); + assert_eq!(r["caret"], json!(3 + INLINE_CHAR.len_utf8())); + s.execute("text.insertInline", &json!({"id": id.0, "at": 4, "symbol": "Dot"})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.plain_text(), format!("abc{INLINE_CHAR}{INLINE_CHAR}")); + assert_eq!(inline_count(&t), 2); + // A document without symbols has nothing to insert. + let mut s = session(); + let id = text(&mut s, "abc"); + assert!(s.execute("text.insertInline", &json!({"id": id.0})).is_err()); +} + +#[test] +fn type_tool_inserts_at_its_caret_and_deletes_the_graphic_whole() { + let mut s = session(); + circle_symbol(&mut s, "Mana", 10.0); + s.execute("symbol.setCurrent", &json!({"name": "Mana"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 50.0, 300.0), v).unwrap(); + } + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Pay ", v).unwrap(); + // The menu item: no params, inside a typing session. + let r = s.execute("text.insertInline", &json!({})).unwrap(); + assert_eq!(r["id"], json!(id.0)); + assert_eq!(s.tool_options()["caret"], json!(4 + INLINE_CHAR.len_utf8())); + s.tool_text(" now", v).unwrap(); + let t = obj(&s, id); + assert_eq!(t.plain_text(), format!("Pay {INLINE_CHAR} now")); + assert_eq!(inline_count(&t), 1); + assert_eq!(t.runs.iter().find(|r| r.inline.is_some()).unwrap().inline.as_ref().unwrap().symbol, "Mana"); + // Back over " now", then the graphic: one Backspace removes it whole. + for _ in 0..5 { + s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap(); + } + let t = obj(&s, id); + assert_eq!(t.plain_text(), "Pay "); + assert_eq!(inline_count(&t), 0); + // Undo brings it back. + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(inline_count(&obj(&s, id)), 1); +} + +#[test] +fn inline_graphics_follow_their_symbol() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap(); + let w0 = obj(&s, id).bounds().unwrap().width(); + // Redefine the symbol as a 2:1 ellipse: the graphic (20 pt tall) widens to 40 pt. + let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap(); + s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap(); + s.execute("symbol.update", &json!({"name": "Dot"})).unwrap(); + let t = obj(&s, id); + let b = t.runs[1].inline.as_ref().unwrap().bounds.unwrap(); + assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}"); + let w1 = t.bounds().unwrap().width(); + assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}"); + // Deleting the symbol leaves an empty slot (nothing drawn), and doesn't crash rendering. + s.execute("symbol.delete", &json!({"name": "Dot"})).unwrap(); + let t = obj(&s, id); + assert!(t.runs[1].inline.as_ref().unwrap().bounds.is_none()); + let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); + let _ = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); +} + +#[test] +fn saved_documents_keep_inline_graphics() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 2})).unwrap(); + let doc = (*s.doc().unwrap().doc).clone(); + let bytes = vectorcraft_format::save(&doc, false); + assert!(String::from_utf8_lossy(&bytes).contains("\"inline\"")); + let mut back = vectorcraft_format::load(&bytes).unwrap(); + // Bounds aren't saved: opening resolves them. + let mut s2 = Session::new(); + back.title = "copy".into(); + s2.add_document(back, None); + let t = obj(&s2, id); + let art = t.runs[1].inline.as_ref().unwrap(); + assert_eq!((art.symbol.as_str(), art.scale), ("Dot", 2.0)); + assert!(art.bounds.is_some()); + assert_eq!(t.cached_bounds, obj(&s, id).cached_bounds); +} + +#[test] +fn create_outlines_turns_inline_graphics_into_symbol_instances() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "a b"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 2, "symbol": "Dot"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let r = s.execute("type.createOutlines", &json!({})).unwrap(); + let g = NodeId(r["ids"][0].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + let kids = d.node(g).unwrap().children().unwrap(); + let inst = kids.iter().filter(|c| matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot")).count(); + assert_eq!(inst, 1); + // Between the outlines of `a` and `b`. + let pos = kids.iter().position(|c| matches!(c.kind, NodeKind::SymbolInstance { .. })).unwrap(); + assert!(pos > 0 && pos + 1 < kids.len(), "{pos} of {}", kids.len()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 03729b9a..d48cebd1 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -501,3 +501,81 @@ fn envelope_commands_with_junk_params_on_envelopes() { failures.dedup(); assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::>().join("\n")); } + +/// Inline graphics in text (a symbol set in a run of type): text commands with `{}`, junk and +/// structured junk (bad symbol names, offsets inside or past the graphic's character, malformed +/// inline runs) never panic, leave an interaction open or break the tree, and the document still +/// round-trips and exports. +#[test] +fn text_commands_with_junk_params_on_inline_graphics() { + use vectorcraft_testkit::fixtures::{ellipse, exec, id_of, select}; + let path = safe_path(); + let setup = || { + let mut s = Fixture::Multi.session(); + let e = ellipse(&mut s, 400.0, 300.0, 20.0, 20.0); + select(&mut s, &[e]); + exec(&mut s, "symbol.new", json!({"name": "Dot"})); + let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "ab cd", "size": 20}))); + exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 2, "symbol": "Dot", "scale": 2})); + select(&mut s, &[t]); + (s, t) + }; + let (_, t) = setup(); + let t = t.0; + let style = json!({"font_family": "Source Sans 3", "size": 12, "fill": {"type": "none"}}); + let structured = [ + ("text.insertInline", json!({"id": t, "at": u64::MAX, "symbol": "Dot", "scale": 100})), + ("text.insertInline", json!({"id": t, "at": 3, "symbol": "Dot", "scale": 1e-300, "shift": -1e5})), + ("text.insertInline", json!({"id": t, "at": 3, "symbol": "\u{0}", "scale": 1})), + ("text.insertInline", json!({"id": t, "at": 3, "symbol": "", "scale": f64::MAX})), + ("text.insertInline", json!({"id": u64::MAX, "symbol": "Dot"})), + ("text.insertInline", json!({"id": t, "at": "3", "symbol": ["Dot"], "scale": "big", "shift": null})), + ("text.editRange", json!({"id": t, "start": 3, "end": 4, "insert": "x"})), + ("text.editRange", json!({"id": t, "start": 4, "end": 3})), + ( + "text.editRange", + json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}\u{FFFC}x", "style": style, "inline": {"symbol": "Dot", "scale": 1e308, "baseline_shift": -1e308}}]}), + ), + ("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": "Missing"}}]})), + ("text.editRange", json!({"id": t, "start": 0, "end": 0, "runs": [{"text": "", "style": style, "inline": {"symbol": "Dot", "scale": -5}}]})), + ("text.editRange", json!({"id": t, "runs": [{"text": "\u{FFFC}", "style": style, "inline": {"symbol": 5}}]})), + ("text.setRangeStyle", json!({"id": t, "start": 3, "end": 4, "size": 1e308, "tracking": -1e308})), + ("text.getRange", json!({"id": t, "start": 3, "end": 4})), + ("type.insert", json!({"text": "\u{FFFC}"})), + ("type.changeCase", json!({"case": "upper"})), + ("type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), + ("type.convertToAreaType", json!({})), + ("type.createOutlines", json!({})), + ("symbol.delete", json!({"name": "Dot"})), + ("symbol.delete", json!({"name": "Dot", "expandInstances": false})), + ("symbol.update", json!({"name": "Dot"})), + ]; + let mut failures = vec![]; + let mut run = |id: &str, p: &Value| { + let (mut s, _) = setup(); + match catch_quiet(|| s.execute(id, p)) { + Err(msg) => failures.push(format!("PANIC {id} {p}: {msg}")), + Ok(r) => { + if s.in_interaction() { + failures.push(format!("{id} {p}: left an interaction open")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{id} {p}: {e}")); + } else if r.is_ok() + && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) + { + failures.push(format!("{id} {p}: {e}")); + } + } + } + }; + for (id, p) in &structured { + run(id, p); + } + for id in ["text.insertInline", "text.editRange", "text.getRange", "text.setRangeStyle", "type.insert"] { + let spec = command_specs().iter().find(|c| c.id == id).unwrap(); + for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + run(id, &p); + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/crates/engine/tests/inline_text.rs b/crates/engine/tests/inline_text.rs new file mode 100644 index 00000000..5b009ea6 --- /dev/null +++ b/crates/engine/tests/inline_text.rs @@ -0,0 +1,139 @@ +//! Inline graphics in text drawn on the canvas and written to SVG and PDF: a symbol (a red +//! circle drawn in code) set inline in a line of type. +// 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 serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::{self, exec, id_of}; +use vectorcraft_testkit::raster; + +/// A session with a red circle symbol "Dot" (its instance deleted) and the point type +/// "Tap now" (20 pt, baseline at y = 100) with the symbol inline after "Tap ". +fn setup(inline: bool) -> (vectorcraft_engine::Session, NodeId) { + let mut s = fixtures::session_with(400.0, 300.0); + fixtures::ellipse(&mut s, 300.0, 250.0, 20.0, 20.0); + exec(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + exec(&mut s, "paint.setStroke", json!({"none": true})); + exec(&mut s, "symbol.new", json!({"name": "Dot"})); + exec(&mut s, "edit.clear", json!({})); + // Type is painted with the current fill: black, not the circle's red. + exec(&mut s, "paint.setFill", json!({"color": "#000000"})); + let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Tap now", "size": 20}))); + if inline { + exec(&mut s, "text.insertInline", json!({"id": id.0, "at": 4, "symbol": "Dot"})); + } + exec(&mut s, "select.none", json!({})); + (s, id) +} + +fn doc(s: &vectorcraft_engine::Session) -> Document { + (*s.doc().unwrap().doc).clone() +} + +fn text(d: &Document, id: NodeId) -> TextObject { + match &d.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// Where the inline art lands, in document space. +fn art_rect(d: &Document, id: NodeId) -> Rect { + let t = text(d, id); + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert_eq!(l.inlines.len(), 1); + t.xf.transform_rect_bbox(l.inlines[0].bounds) +} + +fn red(p: [u8; 3]) -> bool { + p[0] > 200 && p[1] < 80 && p[2] < 80 +} + +#[test] +fn canvas_draws_the_symbol_where_the_layout_puts_it() { + let (s, id) = setup(true); + let d = doc(&s); + let r = art_rect(&d, id); + // 20 pt tall at 20 pt type, on the pen after "Tap ", centred on the cap-height middle. + assert!((r.height() - 20.0).abs() < 0.5 && (r.width() - 20.0).abs() < 0.5, "{r:?}"); + assert!(r.x0 > 50.0 && r.x0 < 70.0, "{r:?}"); + assert!(r.center().y < 100.0 && r.center().y > 90.0, "{r:?}"); + let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0); + let px = |x: f64, y: f64| img.over_white((x * 2.0) as u32, (y * 2.0) as u32); + assert!(red(px(r.center().x, r.center().y)), "{:?} at {:?}", px(r.center().x, r.center().y), r.center()); + // Only there: just outside the circle, no red. + assert!(!red(px(r.x0 - 3.0, r.center().y)) && !red(px(r.center().x, r.y0 - 3.0))); + // Without the graphic nothing is red, and the text after it sits further left. + let (plain, pid) = setup(false); + let pd = doc(&plain); + let img = raster::render_region(&pd, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0); + assert_eq!(img.count(|p| p[0] > 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0); + let w = text(&d, id).bounds().unwrap().width() - text(&pd, pid).bounds().unwrap().width(); + assert!((w - 20.0).abs() < 0.5, "{w}"); +} + +#[test] +fn canvas_applies_the_text_objects_opacity_to_its_inline_art() { + let (mut s, id) = setup(true); + exec(&mut s, "select.set", json!({"ids": [id.0]})); + exec(&mut s, "transparency.set", json!({"opacity": 50})); + let d = doc(&s); + let r = art_rect(&d, id); + let img = raster::render_region(&d, Rect::new(0.0, 0.0, 200.0, 150.0), 2.0); + let p = img.over_white((r.center().x * 2.0) as u32, (r.center().y * 2.0) as u32); + // Half red over white: pink. + assert!(p[0] > 240 && (100..180).contains(&p[1]) && (100..180).contains(&p[2]), "{p:?}"); +} + +fn svg_of(s: &mut vectorcraft_engine::Session, params: Value) -> String { + let v = exec(s, "document.serialize", params); + v["text"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()) +} + +#[test] +fn svg_keeps_live_text_and_draws_the_symbol() { + let (mut s, id) = setup(true); + let svg = svg_of(&mut s, json!({"format": "svg", "outlineText": false})); + // Live text without the object replacement character; the text after the graphic is placed + // after it. + assert!(svg.contains("Tap") && svg.contains("now<"), "{svg}"); + assert!(!svg.contains('\u{FFFC}'), "{svg}"); + // The symbol's art: a def and a of it. + assert!(svg.contains(" 200 && p[1] < 80 && p[2] < 80 && p[3] > 200), 0); +} diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/native_v3.rs index dd6f0a83..7e643de4 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/native_v3.rs @@ -277,3 +277,26 @@ fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert_eq!(old.guides, [Guide::new(false, 20.0)]); } + +#[test] +fn inline_graphics_in_text_round_trip() { + let d = rich_doc(); + let inline = |d: &Document| { + let mut found = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found.extend(t.runs.iter().filter_map(|r| r.inline.clone().map(|a| (r.text.clone(), a.symbol, a.scale, a.baseline_shift)))); + } + }); + found + }; + let want = inline(&d); + assert_eq!(want, vec![("\u{FFFC}".to_string(), "Dot".to_string(), 1.0, 0.0)]); + let bytes = save(&d, false); + let saved = json_of(&bytes).to_string(); + assert!(saved.contains("\"inline\":{") && saved.contains("\"symbol\":\"Dot\""), "{saved}"); + let back = load(&bytes).unwrap(); + assert_eq!(inline(&back), want); + assert_eq!(doc_json(&back), doc_json(&d)); + check_native_roundtrip(&d).unwrap(); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 9390cdc4..5facc5bc 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -464,8 +464,14 @@ pub(crate) struct Exporter<'a> { pub overprint: bool, /// An overprinting fill or stroke was drawn so. pub overprinted: bool, + /// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in + /// text…): deeper ones are left out. + inline_depth: u32, } +/// How deep inline graphics nest before they are left out. +const MAX_INLINE_DEPTH: u32 = 4; + impl<'a> Exporter<'a> { /// An exporter of `doc` writing images as `set` says; layers whose Print option is off are /// left out. @@ -491,6 +497,7 @@ impl<'a> Exporter<'a> { in_layer: 0, overprint: false, overprinted: false, + inline_depth: 0, } } @@ -1280,7 +1287,14 @@ impl Exporter<'_> { true } + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are drawn as their symbols' art + /// (vector paths, like symbol instances) where the layout placed them; their characters + /// write no glyph. fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let tb = t.xf.transform_rect_bbox(layout.bounds); // The object's own fills and strokes paint the whole outline: those below the Characters @@ -1331,6 +1345,18 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); s.pop(); + if self.inline_depth < MAX_INLINE_DEPTH { + self.inline_depth += 1; + for ig in &layout.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + let mut node = (*sym.art).clone(); + // Strokes scale with the art, as on the canvas. + node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); + self.node(s, &node, page, true); + } + self.inline_depth -= 1; + } if let Some((bp, path)) = &all { self.text_items(s, above, bp, path, page, tb); } diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index 2009355c..36cf5ea9 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -199,7 +199,7 @@ fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, wi style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0); match runs.last_mut() { Some(last) if last.style == style => last.text.push_str(&r.text), - _ => runs.push(TextRun { text: r.text.clone(), style }), + _ => runs.push(TextRun { text: r.text.clone(), style, inline: None }), } } // A wrapped line's space, or the end of a paragraph. diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index de2a2e47..aee7de48 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -301,7 +301,7 @@ impl TextLine { if self.runs.iter().all(|(_, t)| t.trim().is_empty()) { return None; } - let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() }); + let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style(), inline: None }); let first = runs.next()?; let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); @@ -397,13 +397,13 @@ fn tracked(runs: Vec, tracking: f64, spaces: &[(usize, f64)]) -> Vec runs.push((i, c.to_string())), } } - let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone() })).collect(); + let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone(), inline: None })).collect(); t.runs = runs; t.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); } diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 4a4ffc00..d73e72f6 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -91,9 +91,9 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { turned.xf *= Affine::rotate(-0.5) * Affine::skew(0.2, 0.0); let mut mixed = TextObject::point(Point::new(200.0, 60.0), "", style(20.0)); mixed.runs = vec![ - TextRun { text: "x".into(), style: style(20.0) }, - TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) } }, - TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } }, + TextRun { text: "x".into(), style: style(20.0), inline: None }, + TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) }, inline: None }, + TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) }, inline: None }, ]; let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0)); on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None }; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 42d68dc0..52597243 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -340,8 +340,17 @@ pub struct Renderer { /// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show /// faded to this opacity over white. dim_images: Option, + /// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing + /// it): drawing stops at [`MAX_INLINE_DEPTH`]. + inline_depth: u32, } +/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. +const MAX_INLINE_DEPTH: u32 = 4; + +/// Set once a missing inline symbol has been logged (it would log every frame otherwise). +static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + #[derive(Clone, Copy, Debug, Default)] pub struct FrameStats { pub drawn: usize, @@ -418,6 +427,7 @@ impl Renderer { stroke_slices: PtrMap::default(), adjusted: Default::default(), dim_images: None, + inline_depth: 0, } } @@ -794,6 +804,7 @@ impl Renderer { && a.blend == vectorcraft_color::BlendMode::Normal && !fx::has_fx(a) { + let t = &*f.doc.inline_resolved(t); let g = match f.text_snap(t) { Some(xf) => Arc::new(text_geom_snapped(t, Some(xf))), None => self.text_geom_of(a, t), @@ -1206,10 +1217,38 @@ impl Renderer { } fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { + let t = &*f.doc.inline_resolved(t); let g = text_geom_snapped(t, f.text_snap(t)); self.draw_text_geom(ctx, f, n, t, &g); } + /// Draw the inline graphics of type `t` (laid out in `g`): each symbol's art through the + /// normal node path, placed by the layout (inside the text's own transparency group, so its + /// opacity and blend mode apply). A missing symbol draws nothing. + fn draw_inlines(&mut self, ctx: &mut RenderContext, f: &Frame, t: &TextObject, g: &TextGeom) { + if g.inlines.is_empty() && !t.runs.iter().any(|r| r.inline.is_some()) { + return; + } + if self.inline_depth >= MAX_INLINE_DEPTH { + return; + } + for r in t.runs.iter().filter_map(|r| r.inline.as_ref()) { + if !f.doc.symbols.iter().any(|s| s.name == r.symbol) && !MISSING_INLINE_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) { + log::warn!("inline graphic shows symbol {:?}, which the document doesn't have: it draws nothing", r.symbol); + } + } + self.inline_depth += 1; + for ig in &g.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; + let mut node = (*sym.art).clone(); + // Strokes scale with the art (it is sized to the type, as in the SVG ``). + node.transform(t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true); + self.draw_node(ctx, f, &node, true); + } + self.inline_depth -= 1; + } + /// Cached glyph geometry for a text node (keyed by Arc identity like paths). fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> Arc { let fonts = vectorcraft_text::FontDb::global().generation(); @@ -1240,6 +1279,7 @@ impl Renderer { ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.set_paint(peniko::Color::BLACK); ctx.stroke_path(&p); + self.draw_inlines(ctx, f, t, g); return; } let tb = t.xf.transform_rect_bbox(g.bounds); @@ -1293,6 +1333,7 @@ impl Renderer { } } overprint(ctx, false); + self.draw_inlines(ctx, f, t, g); if let Some(all) = &all { self.draw_text_items(ctx, f, n, above, all, tb); } @@ -1549,6 +1590,8 @@ struct TextGeom { /// font lacks (drawn from a fallback font): Document Setup's substitution highlights. substituted_fonts: BezPath, substituted_glyphs: BezPath, + /// Inline graphics placed by the layout. + inlines: Vec, } fn text_geom(t: &TextObject) -> TextGeom { @@ -1592,7 +1635,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { cell.apply_affine(Affine::rotate_about(g.angle, g.origin)); target.extend(cell.iter()); } - TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs } + TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } } /// Document Setup's highlight behind substituted fonts and glyphs (screen only). diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 9528f627..8031c3c4 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1597,9 +1597,21 @@ impl Writer<'_> { /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those /// below the Characters row under the characters, the others over them. + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as + /// instances of their symbols (a `` of the symbol's def where it can be shared), all in + /// one group carrying the object's transparency. The live text skips their characters and + /// places the text after each one; type on a path with inline graphics is written as outlines + /// (a `` couldn't leave room for the art). fn text_node(&mut self, n: &Node, t: &TextObject) { - let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) }; - if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; + let inline = t.runs.iter().any(|r| r.inline.is_some()); + let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. })); + let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; + let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); + if !painted && !inline { return chars(self, n); } let id = self.id_attr(n); @@ -1622,6 +1634,14 @@ impl Writer<'_> { let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; let anonymous = std::mem::replace(&mut self.anonymous, true); chars(self, &bare); + for ig in &lay.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let inst = Node::new( + NodeId(u64::MAX), + NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, + ); + self.node(&inst); + } glyphs(self, above); self.anonymous = anonymous; self.depth -= 1; @@ -1684,7 +1704,9 @@ impl Writer<'_> { self.note_fonts(t, &lay); let lines = text_lines(t, &lay, self.opts.fewer_tspans); // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its - // stop: lines with tabs are never anchored. + // stop, and so does the text after an inline graphic: lines with tabs, and text with + // inline graphics, are never anchored. + let has_inline = t.runs.iter().any(|r| r.inline.is_some()); let anchors: Vec> = lines .iter() .map(|l| { @@ -1693,7 +1715,7 @@ impl Writer<'_> { Justify::Right => Some(("end", 1.0)), _ => None, } - .filter(|_| !l.segs.iter().any(|s| s.brk)) + .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) }) .collect(); // Every line anchored alike (the text's only paragraph style, typically): the @@ -1985,8 +2007,18 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - glyphs = rest; } } + let mut after_inline = false; for g in glyphs { let Some(run) = t.runs.get(g.run) else { continue }; + if run.inline.is_some() { + // An inline graphic (written as art): the text after it is placed anew. + if let Some(last) = segs.last_mut() { + last.brk = true; + } + after_inline = true; + cluster = None; + continue; + } if g.len > 0 && cluster == Some(g.byte) { // Another glyph of the same cluster. if let Some(last) = segs.last_mut() { @@ -1998,8 +2030,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let src: String = source(g).chars().filter(|c| *c != '\u{ad}').collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); + let fresh = std::mem::take(&mut after_inline); match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk => { + Some(last) if last.run == g.run && !last.brk && !fresh => { last.text.push_str(&piece); last.advance += g.advance; last.brk = brk; diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index d36217e2..5946665f 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -892,7 +892,7 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { r.text.push(ch); return; } - runs.push(TextRun { text: ch.into(), style }); + runs.push(TextRun { text: ch.into(), style, inline: None }); keys.push(key); } let mut out: Runs = (vec![], vec![]); diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index 2e09a970..3f080ccd 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -26,7 +26,7 @@ fn style(family: &str, font_style: &str) -> CharStyle { fn text(at: Point, runs: &[(&str, CharStyle)]) -> Node { let mut t = TextObject::point(at, "", runs[0].1.clone()); - t.runs = runs.iter().map(|(s, st)| TextRun { text: (*s).into(), style: st.clone() }).collect(); + t.runs = runs.iter().map(|(s, st)| TextRun { text: (*s).into(), style: st.clone(), inline: None }).collect(); Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t))) } diff --git a/crates/svg/tests/options.rs b/crates/svg/tests/options.rs index c17c6e62..d0e863bb 100644 --- a/crates/svg/tests/options.rs +++ b/crates/svg/tests/options.rs @@ -18,12 +18,12 @@ fn doc() -> Document { d.insert(Some(l), usize::MAX, a).unwrap(); let st = CharStyle { size: 12.0, font_family: "Source Sans 3".into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; let mut t = TextObject::point(Point::new(20.0, 120.0), "", st.clone()); - t.runs = vec![TextRun { text: "one two\nthree".into(), style: st.clone() }]; + t.runs = vec![TextRun { text: "one two\nthree".into(), style: st.clone(), inline: None }]; let i = d.alloc_id(); d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t.clone())))).unwrap(); t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 60.0)) }; t.xf = Affine::translate((20.0, 140.0)); - t.runs = vec![TextRun { text: "para one\npara two".into(), style: st }]; + t.runs = vec![TextRun { text: "para one\npara two".into(), style: st, inline: None }]; let i = d.alloc_id(); d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t)))).unwrap(); d diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index ab709943..c6587ab2 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -30,7 +30,7 @@ fn area_doc(runs: Vec, w: f64, justify: Justify) -> Document { } fn run(text: &str, st: CharStyle) -> TextRun { - TextRun { text: text.into(), style: st } + TextRun { text: text.into(), style: st, inline: None } } /// The `` start tags. diff --git a/crates/svg/tests/text_stroke.rs b/crates/svg/tests/text_stroke.rs index c1fe28f5..11a7ece9 100644 --- a/crates/svg/tests/text_stroke.rs +++ b/crates/svg/tests/text_stroke.rs @@ -64,7 +64,7 @@ fn character_stroke_options_round_trip_through_live_text() { #[test] fn a_run_without_the_first_runs_stroke_resets_it() { let mut t = TextObject::point(Point::new(20.0, 100.0), "Ab", stroked()); - t.runs.push(TextRun { text: "cd".into(), style: CharStyle { size: 40.0, ..Default::default() } }); + t.runs.push(TextRun { text: "cd".into(), style: CharStyle { size: 40.0, ..Default::default() }, inline: None }); let svg = export(&text_doc(t, vec![]), &ExportOptions::default()); let second = svg.split(" Session { exec(&mut s, "paint.setFill", json!({"color": "#22aa44"})); select(&mut s, &[inner, clip]); exec(&mut s, "object.clippingMask.make", json!({})); + // Type with an inline graphic: a symbol (a small circle, drawn here) set in a line of text. + let dot = ellipse(&mut s, 440.0, 40.0, 12.0, 12.0); + select(&mut s, &[dot]); + exec(&mut s, "symbol.new", json!({"name": "Dot"})); + let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18}))); + exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"})); let _ = (a, b, c, l); exec(&mut s, "select.none", json!({})); s diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index b58cc04d..ae5e9f5a 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -4,8 +4,15 @@ use std::ops::Range; +use vectorcraft_doc::text::INLINE_CHAR; use vectorcraft_doc::{CharStyle, TextRun}; +/// Is `s` exactly one object replacement character (an inline graphic run's text)? +pub fn is_inline_text(s: &str) -> bool { + let mut c = s.chars(); + c.next() == Some(INLINE_CHAR) && c.next().is_none() +} + /// Total byte length of `runs`. pub fn runs_len(runs: &[TextRun]) -> usize { runs.iter().map(|r| r.text.len()).sum() @@ -58,21 +65,25 @@ pub fn insertion_style(runs: &[TextRun], start: usize, end: usize) -> CharStyle } /// Merge adjacent runs with equal styles and drop empty runs (keeping one run so the object -/// remembers its style). +/// remembers its style). Inline graphic runs never merge: each stays one [`INLINE_CHAR`]; one +/// whose text is anything else (malformed input) becomes plain text. pub fn normalize(runs: &mut Vec) { let first_style = runs.first().map(|r| r.style.clone()); let mut out: Vec = Vec::with_capacity(runs.len()); - for r in runs.drain(..) { + for mut r in runs.drain(..) { if r.text.is_empty() { continue; } + if r.inline.is_some() && !is_inline_text(&r.text) { + r.inline = None; + } match out.last_mut() { - Some(l) if l.style == r.style => l.text.push_str(&r.text), + Some(l) if l.style == r.style && l.inline.is_none() && r.inline.is_none() => l.text.push_str(&r.text), _ => out.push(r), } } if out.is_empty() { - out.push(TextRun { text: String::new(), style: first_style.unwrap_or_default() }); + out.push(TextRun { text: String::new(), style: first_style.unwrap_or_default(), inline: None }); } *runs = out; } @@ -90,7 +101,10 @@ fn split_at(runs: &mut Vec, byte: usize) -> usize { let at = floor_char(&runs[i].text, byte - off); let tail = runs[i].text.split_off(at); let style = runs[i].style.clone(); - runs.insert(i + 1, TextRun { text: tail, style }); + // An inline graphic is one character: a split lands before it (`at` == 0), the empty + // head is dropped by `normalize` and the tail keeps the graphic. + let inline = runs[i].inline.clone(); + runs.insert(i + 1, TextRun { text: tail, style, inline }); return i + 1; } off += len; @@ -103,7 +117,7 @@ fn split_at(runs: &mut Vec, byte: usize) -> usize { /// insertion. pub fn replace_range(runs: &mut Vec, start: usize, end: usize, insert: &str) -> usize { let style = insertion_style(runs, start, end); - replace_range_styled(runs, start, end, &[TextRun { text: insert.to_string(), style }]) + replace_range_styled(runs, start, end, &[TextRun { text: insert.to_string(), style, inline: None }]) } /// Replace `start..end` with styled runs (paste with formatting). Returns the caret after them. @@ -153,7 +167,11 @@ pub fn slice_runs(runs: &[TextRun], start: usize, end: usize) -> Vec { let (a, b) = (start.max(rs), end.min(re)); if a < b { let t = &r.text[floor_char(&r.text, a - rs)..floor_char(&r.text, b - rs)]; - out.push(TextRun { text: t.to_string(), style: r.style.clone() }); + if t.is_empty() { + off = re; + continue; + } + out.push(TextRun { text: t.to_string(), style: r.style.clone(), inline: r.inline.clone() }); } off = re; } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 83bd52f8..aa4c432b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -5,7 +5,7 @@ use std::ops::Range; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::{ - Burasagari, CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, + Burasagari, CharStyle, InlineArt, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; @@ -13,7 +13,7 @@ use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points; use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics}; -use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; +use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; const EPS: f64 = 1e-6; @@ -22,6 +22,8 @@ struct Ctx<'a> { db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, + /// The inline graphic of each run that is one (parallel to `runs`). + inlines: Vec>, default: CharStyle, opts: &'a LayoutOptions, /// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned @@ -41,7 +43,7 @@ impl Ctx<'_> { fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_range(self.db, self.text, r, &self.runs, &self.opts.features, levels, &mut v); + shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); if self.vertical { tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size); // An upright glyph advances down the column by its vertical advance (the font's vertical @@ -57,13 +59,18 @@ impl Ctx<'_> { } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { - let src = self.db.outline(&g.face, g.gid); + let src = if g.inline.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); let lead = if upright { 0.0 } else { g.lead }; - let local = - Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + let local = match &g.inline { + // Inline graphics: art space to glyph space (no font outline). + Some(ib) => Affine::translate((-lead, -g.bshift)) * ib.xf, + None => { + Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy) + } + }; let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. @@ -99,6 +106,9 @@ impl Ctx<'_> { p }; let font_id = g.face.id(); + if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { + self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); + } self.out.glyphs.push(PositionedGlyph { outline, run: g.run, @@ -212,9 +222,12 @@ fn scaled(t: &TextObject, f: f64) -> TextObject { fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); let mut runs = Vec::with_capacity(t.runs.len()); + let mut inlines = Vec::with_capacity(t.runs.len()); let mut off = 0; for r in &t.runs { runs.push((off..off + r.text.len(), &r.style)); + // Only a well-formed inline run (one object replacement character) is a graphic. + inlines.push(r.inline.as_ref().filter(|_| crate::edit::is_inline_text(&r.text))); off += r.text.len(); } let mut paras = Vec::new(); @@ -235,7 +248,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } else { opts }; - let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() }; + let mut cx = + Ctx { on_path: is_on_path, db, text: &text, runs, inlines, default: CharStyle::default(), opts, vertical, out: TextLayout::default() }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next // one to the left). Point type's anchor is on the first column's centre line. Vertical path // type stays on its path (each glyph turned across it in `emit`). @@ -275,6 +289,10 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout g.origin = line_xf * g.origin; g.angle += std::f64::consts::FRAC_PI_2; } + for i in &mut cx.out.inlines { + i.xf = line_xf * i.xf; + i.bounds = line_xf.transform_rect_bbox(i.bounds); + } cx.out.vertical = true; cx.out.line_xf = line_xf; } @@ -414,6 +432,10 @@ fn glyph_em(g: &SGlyph) -> f64 { /// `line_em`: the glyph's em box top, centre or bottom onto the line's (the em box running from /// its centre less half an em to its centre plus half an em above the baseline). Nothing on the /// Roman baseline, or for the line's largest characters. +/// +/// An inline graphic has its run's em (whatever its scale): it counts as one of the line's +/// largest characters only when its run's size is, and moves with the text of its run (its art +/// stays centred on that text's cap height). fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64) -> f64 { use vectorcraft_doc::CharAlign; let k = match a { @@ -486,6 +508,9 @@ fn finish_bounds(out: &mut TextLayout) { add(g.outline.bounding_box()); } } + for i in &out.inlines { + add(i.bounds); + } if !out.on_path { for l in &out.lines { add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent))); @@ -1023,9 +1048,9 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b /// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width /// (、。,.) or half width (、。); not a closing bracket, nor Latin punctuation (Latin text keeps -/// its line breaks and composer). +/// its line breaks and composer). Never an inline graphic (U+FFFC). fn hangs(g: &SGlyph) -> bool { - matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none() + g.inline.is_none() && matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none() } /// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a @@ -1073,6 +1098,7 @@ mod wrapping_tests { xh: 4.0, ch, tcy: None, + inline: None, lead: 0.0, level: unicode_bidi::Level::ltr(), }) @@ -1128,7 +1154,7 @@ mod wrapping_tests { let bidi = para_bidi(text, None); let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels); let mut shaped = Vec::new(); - shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &OtFeatures::default(), levels, &mut shaped); + shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &[], &OtFeatures::default(), levels, &mut shaped); let Some(first) = shaped.first() else { return }; let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count(); if first.byte != 0 || first_end < 2 || first_end >= shaped.len() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 69919b17..0bb3c756 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -135,6 +135,22 @@ pub struct LineInfo { pub region: usize, } +/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) placed by the layout: draw the art of +/// the run's symbol through `xf`. Missing symbols reserve their room but get no entry. +#[derive(Clone, Debug, PartialEq)] +pub struct InlineGlyph { + /// Index of the run (`TextObject::runs`). + pub run: usize, + /// Byte offset of its character in the plain text. + pub byte: usize, + /// Index of its (outline-less) glyph in [`TextLayout::glyphs`]. + pub glyph: usize, + /// The symbol's art at its natural size (`Document::symbol_natural_xf`) → text space. + pub xf: Affine, + /// The art's bounds in text space. + pub bounds: Rect, +} + #[derive(Clone, Debug)] pub struct TextLayout { /// Lines retain inline/block coordinates; glyph geometry is in physical text space. @@ -156,6 +172,8 @@ pub struct TextLayout { /// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled /// by to fit its frame (1.0 when the text is not shrunk). pub fit_scale: f64, + /// Inline graphics, in text order. + pub inlines: Vec, } impl Default for TextLayout { @@ -170,6 +188,7 @@ impl Default for TextLayout { on_path: false, frames: Vec::new(), fit_scale: 1.0, + inlines: Vec::new(), } } } @@ -217,7 +236,8 @@ impl TextLayout { return false; } let mut moved = false; - for g in self.glyphs.iter_mut().filter(|g| g.angle == 0.0) { + let mut shifts = vec![]; + for (gi, g) in self.glyphs.iter_mut().enumerate().filter(|(_, g)| g.angle == 0.0) { let p = to_device * g.origin; let shift = Vec2::new((p.x.round() - p.x) / a, (p.y.round() - p.y) / d); if shift == Vec2::ZERO { @@ -226,8 +246,18 @@ impl TextLayout { g.origin += shift; g.outline.apply_affine(Affine::translate(shift)); g.xf = Affine::translate(shift) * g.xf; + if !self.inlines.is_empty() { + shifts.push((gi, shift)); + } moved = true; } + // Inline graphics move with their glyphs. + for i in &mut self.inlines { + if let Some(&(_, s)) = shifts.iter().find(|(gi, _)| *gi == i.glyph) { + i.xf = Affine::translate(s) * i.xf; + i.bounds = i.bounds + s; + } + } moved } } @@ -475,6 +505,8 @@ mod tests_embed; #[cfg(test)] mod tests_fit; #[cfg(test)] +mod tests_inline; +#[cfg(test)] mod tests_scripts; #[cfg(test)] mod tests_snap; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index f14f9ed1..fd2b3827 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -9,7 +9,8 @@ use unicode_script::{Script, UnicodeScript}; use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer}; use skrifa::MetadataProvider; use skrifa::instance::Size; -use vectorcraft_doc::CharStyle; +use vectorcraft_doc::text::INLINE_CHAR; +use vectorcraft_doc::{CharStyle, InlineArt}; use crate::features::OtFeatures; use crate::fontdb::{FontDb, FontFace}; @@ -50,6 +51,19 @@ pub(crate) struct SGlyph { pub lead: f64, /// Resolved Unicode bidi embedding level (logical source order). pub level: Level, + /// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph. + pub inline: Option, +} + +/// Placement of an inline graphic's art, glyph space being points with the pen at the origin on +/// the baseline (y down). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct InlineBox { + /// The art's bounds in symbol space; `None` when the symbol is missing (the box only takes + /// up room). + pub art: Option, + /// Art -> glyph space. + pub xf: kurbo::Affine, } /// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side @@ -68,7 +82,7 @@ pub(crate) struct Tcy { impl SGlyph { pub fn is_space(&self) -> bool { - matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}') + self.inline.is_none() && matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}') } /// A line may break after this glyph. pub fn break_after(&self) -> bool { @@ -102,6 +116,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph { h.byte = g.byte + g.len; h.len = 0; h.ch = '-'; + h.inline = None; h } @@ -184,14 +199,17 @@ pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { (face.cap_height * k, face.x_height * k) } -/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, and `levels` -/// each byte's bidi embedding level from `range.start` (empty: all left to right). Glyphs come out -/// in logical order, right-to-left ones shaped right to left. +/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, `inlines` +/// (parallel to `runs`) the inline graphic of each run that is one, and `levels` each byte's bidi +/// embedding level from `range.start` (empty: all left to right). Glyphs come out in logical +/// order, right-to-left ones shaped right to left. +#[allow(clippy::too_many_arguments)] pub(crate) fn shape_range( db: &FontDb, text: &str, range: Range, runs: &[(Range, &CharStyle)], + inlines: &[Option<&InlineArt>], feats: &OtFeatures, levels: &[Level], out: &mut Vec, @@ -205,27 +223,44 @@ pub(crate) fn shape_range( // Text runs also carry non-shaping attributes (fill, stroke, etc.). Coalesce adjacent equal // styles so an editor split does not become an accidental OpenType shaping boundary. Keep // original run spans: each emitted cluster is attributed by its source byte below. - let mut spans: Vec<(Range, &CharStyle, Range)> = Vec::new(); + /// A run of text shaped together: its byte range, style and source runs, or one inline graphic. + struct ShapeSpan<'a> { + bytes: Range, + st: &'a CharStyle, + source_runs: Range, + art: Option<&'a InlineArt>, + } + let mut spans: Vec> = Vec::new(); for (ri, (rr, st)) in runs.iter().enumerate() { let a = rr.start.max(range.start); let b = rr.end.min(range.end); if a >= b || text.get(a..b).is_none() { continue; } - if let Some((previous, previous_style, source_runs)) = spans.last_mut() - && previous.end == a - && *previous_style == *st + let art = inlines.get(ri).copied().flatten(); + // An inline graphic is a span of its own: never merged with its neighbours. + if art.is_none() + && let Some(previous) = spans.last_mut() + && previous.art.is_none() + && previous.bytes.end == a + && *previous.st == **st { - previous.end = b; - source_runs.end = ri + 1; + previous.bytes.end = b; + previous.source_runs.end = ri + 1; continue; } - spans.push((a..b, *st, ri..ri + 1)); + spans.push(ShapeSpan { bytes: a..b, st, source_runs: ri..ri + 1, art }); } - for (span, st, source_runs) in spans { - let a = span.start; - let b = span.end; + for ShapeSpan { bytes, st, source_runs, art } in spans { + 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')) { + 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 pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). @@ -299,6 +334,62 @@ fn shaping_script(c: char) -> Script { } } +/// The synthetic glyph of an inline graphic run (`range` = its one character). +/// +/// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its +/// vertical centre on the middle of the cap height raised by the inline's shift (see +/// [`InlineArt`]). Its advance is the scaled art width plus tracking; the glyph's ascent and +/// descent grow to the art's extent so a tall graphic opens up its line. A missing symbol takes a +/// square of the same height and draws nothing. +fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usize, range: Range, text: &str, level: Level) -> SGlyph { + let size = if st.size.is_finite() { st.size.max(0.0) } else { 0.0 }; + let vs = if st.v_scale.is_finite() { st.v_scale / 100.0 } else { 1.0 }; + let km = size / face.upem; + let (ascent, descent, cap, xh) = (face.ascent * km * vs, face.descent * km * vs, face.cap_height * km * vs, face.x_height * km * vs); + let h = art.safe_scale() * size; + let mid = cap * 0.5 + art.safe_shift(); + let art_bounds = art.bounds.filter(|b| b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite()); + let (xf, width, drawn) = match art_bounds { + Some(b) if h > 0.0 => { + let k = h / b.height(); + let xf = kurbo::Affine::translate((0.0, -mid)) * kurbo::Affine::scale(k) * kurbo::Affine::translate((-b.x0, -(b.y0 + b.y1) * 0.5)); + (xf, b.width() * k, Some(b)) + } + _ => (kurbo::Affine::IDENTITY, h, None), + }; + let tracking = st.tracking / 1000.0 * size; + let adv = width + if tracking.is_finite() { tracking } else { 0.0 }; + SGlyph { + face: face.clone(), + gid: 0, + byte: range.start, + len: range.len().max(1), + run, + adv: if adv.is_finite() { adv } else { 0.0 }, + dx: 0.0, + dy: 0.0, + // Not used to draw (the art has its own transform), but they make the glyph's em box + // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a + // character of its run's size, whatever its scale (see `layout::glyph_em`). + sx: if km.is_finite() { km * vs } else { 0.0 }, + sy: if km.is_finite() { km * vs } else { 0.0 }, + bshift: if st.baseline_shift.is_finite() { st.baseline_shift } else { 0.0 }, + rotation: 0.0, + ascent: ascent.max(mid + h * 0.5), + descent: descent.max(h * 0.5 - mid), + leading: st.effective_leading(), + cap, + xh, + ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR), + tcy: None, + lead: 0.0, + // U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in + // RTL text like any other glyph once the line is reordered. + level, + inline: Some(InlineBox { art: drawn, xf }), + } +} + /// A piece of one run shaped in one go: one face and, for Small Caps, one case. struct Segment<'a> { range: Range, @@ -424,6 +515,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe tcy: None, lead: 0.0, level: *level, + inline: None, }); } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c67c6259..55a7e186 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -386,8 +386,8 @@ fn fallback_font_per_character() { fn multiple_runs_and_styles() { let mut t = point("", style(10.0)); t.runs = vec![ - TextRun { text: "Big".into(), style: style(40.0) }, - TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) } }, + TextRun { text: "Big".into(), style: style(40.0), inline: None }, + TextRun { text: "small".into(), style: CharStyle { font_family: "Inter".into(), ..style(10.0) }, inline: None }, ]; let l = layout(db(), &t); assert_eq!(l.glyphs.len(), 8); @@ -567,7 +567,7 @@ fn em_box_top_leading_hangs_lines_from_the_line_above() { } // 40 pt over 20 pt (leading 60 and 30). let mut mixed = point("", st(40.0)); - mixed.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小".into(), style: st(20.0) }]; + mixed.runs = vec![TextRun { text: "大\n".into(), style: st(40.0), inline: None }, TextRun { text: "小".into(), style: st(20.0), inline: None }]; let roman = lay(&mixed, LeadingModel::RomanBaseline); assert!((roman.lines[1].baseline - roman.lines[0].baseline - 30.0).abs() < 0.01, "the small line's leading, above it"); let em = lay(&mixed, LeadingModel::EmBoxTop); @@ -591,10 +591,10 @@ fn leading_model_is_per_paragraph() { let top = |size: f64| 0.88 * size; let mut t = point("", st(40.0)); t.runs = vec![ - TextRun { text: "大\n".into(), style: st(40.0) }, - TextRun { text: "小\n".into(), style: st(20.0) }, - TextRun { text: "大\n".into(), style: st(40.0) }, - TextRun { text: "小".into(), style: st(20.0) }, + TextRun { text: "大\n".into(), style: st(40.0), inline: None }, + TextRun { text: "小\n".into(), style: st(20.0), inline: None }, + TextRun { text: "大\n".into(), style: st(40.0), inline: None }, + TextRun { text: "小".into(), style: st(20.0), inline: None }, ]; t.set_paragraph_styles(vec![ model(LeadingModel::RomanBaseline), @@ -631,8 +631,8 @@ fn character_alignment_lines_small_characters_up_with_the_largest_em_box() { let mut t = point("", style(40.0)); t.vertical = vertical_type; t.runs = vec![ - TextRun { text: "大".into(), style: style(40.0) }, - TextRun { text: "小".into(), style: CharStyle { char_align: a, ..style(20.0) } }, + TextRun { text: "大".into(), style: style(40.0), inline: None }, + TextRun { text: "小".into(), style: CharStyle { char_align: a, ..style(20.0) }, inline: None }, ]; let l = layout(db(), &t); // How far the small character's origin sits above the big one's (to the right, vertical). diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index b4a77b79..cc03d8a9 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -148,9 +148,9 @@ fn identical_style_arabic_run_splits_keep_joining_and_source_attribution() { let text = "بب"; let mut single = TextObject::point(Point::ZERO, text, style.clone()); - single.runs = vec![TextRun { text: text.to_string(), style: style.clone() }]; + single.runs = vec![TextRun { text: text.to_string(), style: style.clone(), inline: None }]; let mut split = TextObject::point(Point::ZERO, text, style.clone()); - split.runs = vec![TextRun { text: "ب".into(), style: style.clone() }, TextRun { text: "ب".into(), style }]; + split.runs = vec![TextRun { text: "ب".into(), style: style.clone(), inline: None }, TextRun { text: "ب".into(), style, inline: None }]; let single = layout(db, &single); let split = layout(db, &split); @@ -173,8 +173,10 @@ fn different_style_arabic_boundary_receives_joining_context() { assert!(joined.glyphs.iter().any(|glyph| glyph.gid != face.glyph_for('ب')), "reference must use contextual beh forms"); let mut split = TextObject::point(Point::ZERO, text, style.clone()); - split.runs = - vec![TextRun { text: "ب".into(), style: style.clone() }, TextRun { text: "ب".into(), style: CharStyle { size: style.size * 1.5, ..style } }]; + split.runs = vec![ + TextRun { text: "ب".into(), style: style.clone(), inline: None }, + TextRun { text: "ب".into(), style: CharStyle { size: style.size * 1.5, ..style }, inline: None }, + ]; let split = layout(db, &split); assert_eq!(joined.glyphs.len(), 2); assert_eq!(split.glyphs.len(), 2); @@ -194,12 +196,12 @@ fn shape_range_handles_empty_runs_and_selected_utf8_subranges() { let runs = [(0..text.len(), &style)]; let mut glyphs = Vec::new(); - shape::shape_range(db, text, 0..0, &runs, &OtFeatures::default(), &[], &mut glyphs); - shape::shape_range(db, text, 0..text.len(), &[], &OtFeatures::default(), &[], &mut glyphs); - shape::shape_range(db, text, 1..2, &runs, &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 0..0, &runs, &[], &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 0..text.len(), &[], &[], &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 1..2, &runs, &[], &OtFeatures::default(), &[], &mut glyphs); assert!(glyphs.is_empty()); - shape::shape_range(db, text, 1..text.len(), &runs, &OtFeatures::default(), &[], &mut glyphs); + shape::shape_range(db, text, 1..text.len(), &runs, &[], &OtFeatures::default(), &[], &mut glyphs); assert!(!glyphs.is_empty()); assert!(glyphs.iter().all(|g| (1..text.len()).contains(&g.byte))); } diff --git a/crates/text/src/tests_fit.rs b/crates/text/src/tests_fit.rs index 87fdb51d..195d6ccb 100644 --- a/crates/text/src/tests_fit.rs +++ b/crates/text/src/tests_fit.rs @@ -132,7 +132,7 @@ fn shrink_text_follows_top_to_top_leading_and_character_alignment() { let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(20.0)); t.para.leading_model = LeadingModel::EmBoxTop; let big = CharStyle { size: 28.0, leading: Some(32.0), char_align: CharAlign::EmBoxCenter, ..CharStyle::default() }; - t.runs.insert(0, TextRun { text: "Big ".into(), style: big }); + t.runs.insert(0, TextRun { text: "Big ".into(), style: big, inline: None }); for r in &mut t.runs { r.style.char_align = CharAlign::EmBoxCenter; } diff --git a/crates/text/src/tests_inline.rs b/crates/text/src/tests_inline.rs new file mode 100644 index 00000000..6a435ade --- /dev/null +++ b/crates/text/src/tests_inline.rs @@ -0,0 +1,298 @@ +//! Inline graphics in text: a symbol's art laid out like a glyph. + +use super::*; +use kurbo::{Affine, Shape}; +use vectorcraft_doc::text::INLINE_CHAR; +use vectorcraft_doc::{CharStyle, InlineArt, Justify, TextKind, TextRun}; +use vectorcraft_geom::PathData; + +use crate::edit; + +fn db() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(vec![])) +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// Art 20 × 10 (2:1), as a symbol's resolved bounds. +fn art(scale: f64) -> InlineArt { + InlineArt { scale, bounds: Some(Rect::new(-10.0, -5.0, 10.0, 5.0)), ..InlineArt::new("mana") } +} + +/// Point type: `before`, the inline graphic, `after`. +fn text(before: &str, a: InlineArt, after: &str, size: f64) -> TextObject { + let mut t = TextObject::point(Point::ZERO, before, style(size)); + t.runs.push(TextRun::inline(a, style(size))); + t.runs.push(TextRun::new(after, style(size))); + t +} + +fn inline_glyph(l: &TextLayout) -> &PositionedGlyph { + l.glyphs.iter().find(|g| g.run == 1).expect("inline glyph") +} + +#[test] +fn inline_art_advances_by_its_scaled_width_and_centres_on_the_cap_height() { + let t = text("A", art(1.0), "B", 20.0); + let l = layout(db(), &t); + let g = inline_glyph(&l); + // 20 pt tall → 40 pt wide (2:1), no outline of its own. + assert!((g.advance - 40.0).abs() < 1e-6, "{}", g.advance); + assert!(g.outline.elements().is_empty()); + assert_eq!(g.len, INLINE_CHAR.len_utf8()); + assert_eq!(l.inlines.len(), 1); + let ig = &l.inlines[0]; + assert_eq!((ig.run, ig.byte, ig.glyph), (1, 1, l.glyphs.iter().position(|g| g.run == 1).unwrap())); + // Left edge on the pen; vertical centre half the cap height above the baseline. + let face = db().face("Source Sans 3", "Regular").unwrap(); + let cap = face.cap_height * 20.0 / face.upem; + let b = ig.bounds; + assert!((b.x0 - g.origin.x).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6 && (b.height() - 20.0).abs() < 1e-6, "{b:?}"); + assert!((b.center().y + cap * 0.5).abs() < 1e-6, "{b:?} cap {cap}"); + // The art's transform maps its bounds there. + let mapped = ig.xf.transform_rect_bbox(Rect::new(-10.0, -5.0, 10.0, 5.0)); + assert!((mapped.x0 - b.x0).abs() < 1e-6 && (mapped.y1 - b.y1).abs() < 1e-6); + // `B` follows it; the layout bounds include it. + let next = l.glyphs.iter().find(|g| g.run == 2).unwrap(); + assert!((next.origin.x - (g.origin.x + 40.0)).abs() < 1e-6); + assert!(l.bounds.contains(b.origin()) && l.bounds.x1 >= b.x1 - 1e-9); +} + +#[test] +fn shift_tracking_and_missing_symbols() { + let up = InlineArt { baseline_shift: 4.0, ..art(1.0) }; + let a = layout(db(), &text("A", art(1.0), "B", 20.0)).inlines[0].bounds; + let b = layout(db(), &text("A", up, "B", 20.0)).inlines[0].bounds; + assert!((a.y0 - b.y0 - 4.0).abs() < 1e-6, "{a:?} {b:?}"); + // Tracking adds to the advance as it does after a glyph. + let mut t = text("A", art(1.0), "B", 20.0); + t.runs[1].style.tracking = 100.0; + assert!((inline_glyph(&layout(db(), &t)).advance - 42.0).abs() < 1e-6); + // Unresolved (missing symbol): a square of the height, nothing to draw. + let t = text("A", InlineArt::new("gone"), "B", 20.0); + let l = layout(db(), &t); + assert!((inline_glyph(&l).advance - 20.0).abs() < 1e-6); + assert!(l.inlines.is_empty()); + // Junk numbers stay finite. + let t = text("A", InlineArt { scale: f64::NAN, baseline_shift: f64::INFINITY, ..art(1.0) }, "B", 20.0); + let l = layout(db(), &t); + assert!(l.bounds.is_finite() && l.glyphs.iter().all(|g| g.advance.is_finite())); +} + +#[test] +fn tall_inline_art_opens_up_its_line() { + let plain = layout(db(), &text("Ab", art(1.0), "cd", 20.0)); + let tall = layout(db(), &text("Ab", art(3.0), "cd", 20.0)); + let face = db().face("Source Sans 3", "Regular").unwrap(); + let cap = face.cap_height * 20.0 / face.upem; + // At scale 1 the art stays inside the font's ascent and descent: the line doesn't change. + let font_only = layout(db(), &TextObject::point(Point::ZERO, "Abcd", style(20.0))); + assert!((plain.lines[0].ascent - font_only.lines[0].ascent).abs() < 1e-6); + assert!((plain.lines[0].descent - font_only.lines[0].descent).abs() < 1e-6); + // 60 pt tall: half above the cap-height middle, half below. + assert!((tall.lines[0].ascent - (cap * 0.5 + 30.0)).abs() < 1e-6, "{:?}", tall.lines[0]); + assert!((tall.lines[0].descent - (30.0 - cap * 0.5)).abs() < 1e-6); + // A second line moves down by the taller leading (Auto: 120% of the size) only where the + // art is; the line's band (caret, selection, hit testing) covers the art. + let mut t = text("Ab", art(3.0), "cd\nef", 20.0); + t.runs[1].style.leading = None; + let l = layout(db(), &t); + assert_eq!(l.lines.len(), 2); + let (top, bottom) = caret_position(&l, 3); + assert!(top.y <= l.inlines[0].bounds.y0 + 1e-6 && bottom.y >= l.inlines[0].bounds.y1 - 1e-6); + let mid = l.inlines[0].bounds.center(); + assert_eq!(l.line_of(hit_byte(&l, mid)), 0); +} + +#[test] +fn line_breaking_moves_an_inline_graphic_like_a_word() { + // The frame is just wider than "tap tap ": the 40 pt graphic doesn't fit after it. + let w: f64 = layout(db(), &TextObject::point(Point::ZERO, "tap tap ", style(20.0))).glyphs.iter().map(|g| g.advance).sum(); + let frame = Rect::new(0.0, 0.0, w + 10.0, 200.0); + let area = |t: &mut TextObject| { + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = Justify::Left; + }; + let mut t = text("tap tap ", art(1.0), " done", 20.0); + area(&mut t); + let l = layout(db(), &t); + let g = inline_glyph(&l); + assert_eq!(g.line, 1, "the graphic wraps to the next line"); + // It starts that line, at the frame's left edge, its art with it. + assert!((g.origin.x - l.lines[1].x0).abs() < 1e-6 && l.lines[1].x0.abs() < 1e-6); + assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6); + assert!(l.inlines[0].bounds.center().y > l.lines[0].baseline); + // No break between the graphic and punctuation stuck to it. + let mut t2 = text("tap tap ", art(1.0), ".", 20.0); + area(&mut t2); + let l2 = layout(db(), &t2); + assert_eq!(l2.glyphs.iter().find(|g| g.run == 2).unwrap().line, inline_glyph(&l2).line); +} + +#[test] +fn inline_art_on_a_path_and_in_vertical_type_moves_with_the_text() { + let mut t = text("ab", art(1.0), "cd", 20.0); + let path = kurbo::Line::new((0.0, 0.0), (400.0, 0.0)).to_path(0.1); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&path), start: 0.0, end: None }; + let l = layout(db(), &t); + assert_eq!(l.inlines.len(), 1); + let g = inline_glyph(&l); + assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6); + let mut v = text("ab", art(1.0), "cd", 20.0); + v.vertical = true; + let l = layout(db(), &v); + assert_eq!(l.inlines.len(), 1); + assert!(l.bounds.contains(l.inlines[0].bounds.center())); +} + +#[test] +fn snapping_moves_inline_art_with_its_glyph() { + let mut l = layout(db(), &text("A", art(1.0), "B", 20.0)); + let before = (l.inlines[0].bounds, inline_glyph(&l).origin); + l.snap_to_pixels(Affine::translate((0.3, 0.3))); + let after = (l.inlines[0].bounds, inline_glyph(&l).origin); + let d = after.1 - before.1; + assert!(((after.0.x0 - before.0.x0) - d.x).abs() < 1e-9 && ((after.0.y0 - before.0.y0) - d.y).abs() < 1e-9); +} + +#[test] +fn editing_keeps_inline_runs_whole() { + let t = text("ab", art(1.0), "cd", 12.0); + let mut runs = t.runs.clone(); + // Same style on both sides: the plain runs still never merge into the graphic. + edit::normalize(&mut runs); + assert_eq!(runs.len(), 3); + assert!(runs[1].inline.is_some() && runs[1].text == INLINE_CHAR.to_string()); + // Typing next to it makes plain text. + let caret = edit::replace_range(&mut runs, 5, 5, "X"); + assert_eq!(caret, 6); + assert_eq!(runs.len(), 3); + assert_eq!(runs[2].text, "Xcd"); + let caret = edit::replace_range(&mut runs, 2, 2, "Y"); + assert_eq!((caret, runs[0].text.as_str()), (3, "abY")); + // An offset inside its character lands before it: nothing splits it. + let plain: String = runs.iter().map(|r| r.text.as_str()).collect(); + let at = plain.find(INLINE_CHAR).unwrap(); + edit::replace_range(&mut runs, at + 1, at + 1, "Z"); + assert!(runs.iter().any(|r| r.inline.is_some() && r.text == INLINE_CHAR.to_string())); + // Backspace over it (its one character) removes the whole graphic. + let plain: String = runs.iter().map(|r| r.text.as_str()).collect(); + let at = plain.find(INLINE_CHAR).unwrap(); + let prev = edit::prev_char(&plain, at + INLINE_CHAR.len_utf8()); + assert_eq!(prev, at); + edit::replace_range(&mut runs, prev, at + INLINE_CHAR.len_utf8(), ""); + assert!(runs.iter().all(|r| r.inline.is_none())); + assert!(!runs.iter().any(|r| r.text.contains(INLINE_CHAR))); +} + +#[test] +fn copying_keeps_the_graphic_and_plain_text_shows_the_replacement_character() { + let t = text("ab", art(2.0), "cd", 12.0); + let copy = edit::slice_runs(&t.runs, 1, 6); + let plain: String = copy.iter().map(|r| r.text.as_str()).collect(); + assert_eq!(plain, format!("b{INLINE_CHAR}c")); + assert_eq!(copy[1].inline.as_ref().map(|a| a.scale), Some(2.0)); + // Half of its character is nothing (no empty graphic runs). + let half = edit::slice_runs(&t.runs, 2, 3); + assert!(half.is_empty(), "{half:?}"); + // Pasting it back keeps it. + let mut runs = t.runs.clone(); + edit::replace_range_styled(&mut runs, 0, 0, ©); + assert_eq!(runs.iter().filter(|r| r.inline.is_some()).count(), 2); +} + +#[test] +fn malformed_inline_runs_are_plain_text() { + let mut runs = vec![TextRun { text: "abc".into(), style: style(12.0), inline: Some(art(1.0)) }]; + edit::normalize(&mut runs); + assert!(runs[0].inline.is_none()); + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = vec![TextRun { text: format!("{INLINE_CHAR}{INLINE_CHAR}"), style: style(12.0), inline: Some(art(1.0)) }]; + let l = layout(db(), &t); + assert!(l.inlines.is_empty()); +} + +/// An inline graphic is an object replacement character (U+FFFC, a bidi neutral): in right-to-left +/// text it takes the direction of the text around it and the line's reordering places it, between +/// two Hebrew words in reading order (right to left); between two English words set right to left +/// it stays between them. +#[test] +fn inline_art_in_a_right_to_left_paragraph_is_placed_by_the_bidi_reordering() { + use vectorcraft_doc::ParaDirection; + let db = FontDb::global(); + let order = |before: &str, after: &str, direction: Option| { + let mut t = text(before, art(1.0), after, 20.0); + (t.para.justify, t.para.direction) = (Justify::Auto, direction); + let l = layout(db, &t); + let x = |run: usize| l.glyphs.iter().filter(|g| g.run == run).map(|g| g.origin.x).fold(f64::NAN, f64::min); + let g = inline_glyph(&l).clone(); + assert_eq!(l.inlines.len(), 1); + // The art sits at its glyph, inside the line. + assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6, "{:?} {g:?}", l.inlines[0]); + (x(0), g.origin.x, x(2), g.rtl, l.lines[0].rtl) + }; + // Hebrew: the first word on the right, the graphic left of it, the second word further left. + let (first, inline, second, inline_rtl, line_rtl) = order("שלום ", " עולם", None); + assert!(line_rtl && inline_rtl); + assert!(second < inline && inline < first, "{second} < {inline} < {first}"); + // English set right to left: the words keep their order with the graphic between them. + let (first, inline, second, inline_rtl, line_rtl) = order("Hello", "world", Some(ParaDirection::RightToLeft)); + assert!(line_rtl && !inline_rtl); + assert!(first < inline && inline < second, "{first} < {inline} < {second}"); + // At the end of a right-to-left paragraph it is the leftmost thing on the line. + let (first, inline, _, inline_rtl, _) = order("שלום", "", None); + assert!(inline_rtl && inline < first); +} + +/// Character Alignment: an inline graphic's em box is its run's, whatever its scale. It moves with +/// its run's text when that is smaller than the line's largest characters, and a big graphic in +/// small text doesn't make its line's small characters move. +#[test] +fn character_alignment_treats_inline_art_as_a_character_of_its_run_size() { + use vectorcraft_doc::CharAlign; + let centre_y = |a: CharAlign, scale: f64, big: f64| { + let mut t = TextObject::point(Point::ZERO, "M", style(big)); + t.runs.push(TextRun::inline(art(scale), CharStyle { char_align: a, ..style(20.0) })); + t.runs.push(TextRun::new("x", CharStyle { char_align: a, ..style(20.0) })); + let l = layout(db(), &t); + let x = l.glyphs.iter().find(|g| g.run == 2).map(|g| g.origin.y).unwrap(); + (l.inlines[0].bounds.center().y, x) + }; + let face = db().face("Source Sans 3", "Regular").unwrap(); + let c = face.ideographic_centre(); + // Next to a 40 pt character, the 20 pt run (graphic and letter alike) moves up by the same. + let (roman, roman_x) = centre_y(CharAlign::RomanBaseline, 1.0, 40.0); + for (a, k) in [(CharAlign::EmBoxTop, 0.5), (CharAlign::EmBoxCenter, 0.0), (CharAlign::EmBoxBottom, -0.5)] { + let (y, x) = centre_y(a, 1.0, 40.0); + let want = (c + k) * 20.0; + assert!((roman - y - want).abs() < 1e-6, "{a:?}: {} vs {want}", roman - y); + assert!(((roman_x - x) - (roman - y)).abs() < 1e-6, "{a:?}: the graphic moves with its run's letters"); + } + // A 60 pt tall graphic in a line of 20 pt text: the line's largest em is still 20 pt, so + // nothing moves. + let (roman, roman_x) = centre_y(CharAlign::RomanBaseline, 3.0, 20.0); + let (top, top_x) = centre_y(CharAlign::EmBoxTop, 3.0, 20.0); + assert!((roman - top).abs() < 1e-6 && (roman_x - top_x).abs() < 1e-6); +} + +/// Top-to-Top leading measures from the em box tops: an inline graphic's is its run's, so a line +/// with a tall graphic is spaced like its text (as Roman leading spaces it by its run's leading). +#[test] +fn top_to_top_leading_spaces_inline_art_by_its_run() { + use vectorcraft_doc::LeadingModel; + let lines = |scale: f64| { + let mut t = text("Ab", art(scale), "cd\nef", 20.0); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1)) }; + t.para.leading_model = LeadingModel::EmBoxTop; + let l = layout(db(), &t); + assert_eq!(l.lines.len(), 2); + (l.lines[0].baseline, l.lines[1].baseline) + }; + let (small0, small1) = lines(1.0); + let (tall0, tall1) = lines(3.0); + assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); +} diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 70fce4fe..136c7cc3 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -15,7 +15,7 @@ fn style(size: f64) -> CharStyle { } fn run(text: &str, size: f64) -> TextRun { - TextRun { text: text.into(), style: style(size) } + TextRun { text: text.into(), style: style(size), inline: None } } fn area_path(text: &str, st: CharStyle, frame: &BezPath, justify: Justify) -> TextObject { @@ -613,7 +613,7 @@ fn vertical_align_with_top_to_top_leading() { use vectorcraft_doc::LeadingModel; let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) }; let mut t = area("", st(40.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left); - t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小\n中".into(), style: st(20.0) }]; + t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0), inline: None }, TextRun { text: "小\n中".into(), style: st(20.0), inline: None }]; t.para.leading_model = LeadingModel::EmBoxTop; let top = layout(db(), &t); assert_eq!(top.lines.len(), 3); diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index cbde2985..357b9ba3 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -97,10 +97,10 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: }; let mut head = slice_runs(&rest, 0, n); if head.is_empty() { - head.push(TextRun { text: String::new(), style: style_at(&rest, n) }); + head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); } let tail = slice_runs(&rest, n, len); - rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail }; + rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); out.push(Slice { runs: head, paras: head_paras }); @@ -126,7 +126,7 @@ mod tests { let db = FontDb::global(); let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0)); let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here."; - let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }]; + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}"); @@ -147,7 +147,7 @@ mod tests { let db = FontDb::global(); let (a, b) = (frame(0.0), frame(200.0)); let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird."; - let story = vec![TextRun { text, style: CharStyle::default() }]; + let story = vec![TextRun { text, style: CharStyle::default(), inline: None }]; let style = |j| ParaStyle { justify: j, ..Default::default() }; use vectorcraft_doc::Justify::*; let paras = vec![style(Left), style(Center), style(Right)]; @@ -178,7 +178,7 @@ mod tests { let (a, b) = (frame(0.0), frame(200.0)); let st = CharStyle { size: 20.0, ..Default::default() }; let parts: Vec> = - distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }], &[]).into_iter().map(|s| s.runs).collect(); + distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st, inline: None }], &[]).into_iter().map(|s| s.runs).collect(); assert_eq!(parts[0][0].text, "Hi"); assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0)); } diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index b925b0fd..9a044615 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -211,7 +211,7 @@ fn select_all_and_styled_paste() { let c = cx(&d, &sel, &p); tool.key(&c, ToolKey::Right, SHIFT); assert_eq!(tool.sel(), (0, 3)); - let big = TextRun { text: "XY".into(), style: CharStyle { size: 40.0, ..Default::default() } }; + let big = TextRun { text: "XY".into(), style: CharStyle { size: 40.0, ..Default::default() }, inline: None }; tool.set_option("copy", &serde_json::to_value(vec![big.clone()]).unwrap()); let acts = tool.text_input(&c, "XY"); let Action::Preview(_, params) = acts.last().unwrap() else { panic!() }; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1951b1c1..018633ad 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -283,6 +283,7 @@ Create Outlines Převést na křivky Optical Margin Alignment Optické zarovnání okrajů Fill with Placeholder Text Vyplnit zástupným textem + Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky Hide Hidden Characters Skrýt skryté znaky Type Orientation Orientace textu diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 74e776f3..51a257b4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -922,6 +922,7 @@ Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra Fill Rule Regla de relleno Fill with Placeholder Text Rellenar con texto de muestra + Insert Inline Symbol Insertar símbolo en el texto Fill: Relleno: Fill: click for swatches, Shift-click for the color mixer Relleno: clic para muestras, Shift+clic para el mezclador de colores Find Buscar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4c0e0fed..37219b10 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1189,6 +1189,7 @@ Input White Bianco di input Insert Break Character Inserisci carattere di interruzione Insert Character Inserisci carattere + Insert Inline Symbol Inserisci simbolo nel testo Insert Special Character Inserisci carattere speciale Insert Whitespace Character Inserisci carattere spazio Inspect Document Ispeziona documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 648c9988..65550959 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -319,6 +319,7 @@ Create Outlines アウトラインを作成 Optical Margin Alignment 最適なマージン揃え Fill with Placeholder Text サンプルテキストの割り付け + Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 Type Orientation 組み方向 Horizontal 横書き diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6e3cdf5c..c7a1e2ff 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -863,6 +863,7 @@ Fill and Stroke Preenchimento e Traçado Fill new strokes Preencher novos traçados Fill with Placeholder Text Preencher com Texto de Placeholder + Insert Inline Symbol Inserir Símbolo no Texto Fill: Preenchimento: Find Localizar Find Font Localizar Fonte diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a8f8390c..d76dbb5e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1187,6 +1187,7 @@ Input White Входной белый Insert Break Character Вставить символ разрыва Insert Character Вставить символ + Insert Inline Symbol Вставить символ в текст Insert Special Character Вставить специальный символ Insert Whitespace Character Вставить символ пробела Inspect Document Проверить документ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 85ab6edb..57b05f94 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -916,6 +916,7 @@ Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象 Fill Rule 填充规则 Fill with Placeholder Text 用占位文本填充 + Insert Inline Symbol 插入行内符号 Fill: 填色: Fill: click for swatches, Shift-click for the color mixer 填色:单击开启色板,按住 Shift 单击开启混色器 Find 查找 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 52b2562e..0b72db20 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -939,6 +939,7 @@ Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件 Fill Rule 填色規則 Fill with Placeholder Text 以預留位置文字填滿 + Insert Inline Symbol 插入行內符號 Fill: 填色: Fill: click for swatches, Shift-click for the color mixer 填色:按一下開啟色票,按住 Shift 按一下開啟混色器 Find 尋找 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a73a365..a9f7a35f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2263,6 +2263,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Optical Margin Alignment"), Sep, c("Fill with Placeholder Text", "type.fillPlaceholder"), + c("Insert Inline Symbol", "text.insertInline"), Sep, c("Show Hidden Characters", "type.hiddenCharacters"), sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), diff --git a/docs/mcp.md b/docs/mcp.md index bcece579..afa32e4c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -775,6 +775,31 @@ some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone. +## Inline graphics in type + +A document symbol can sit in a line of type like a character (beyond Illustrator; like InDesign's inline +anchored objects), e.g. mana symbols in card rules text. `text.insertInline {id?, at?, symbol?, scale?: 1, +shift?: 0}` inserts one at byte offset `at` (default: the Type tool's selection, which it replaces) of text `id` +(default: the text being edited), showing `symbol` (default: the Symbols panel's current symbol) → `{id, caret}`; +Type → Insert Inline Symbol does the same at the caret. The graphic is one character, U+FFFC, in a run of its own: +`text.getRange` reports it as `{text: "\uFFFC", style, inline: {symbol, scale, baseline_shift}}`, plain text (and +copied text) shows U+FFFC, and `text.editRange` with styled `runs` can insert one. Typing over it, Backspace and +Delete remove it whole; neighbouring text never merges into it. + +Layout: the art is scaled uniformly to `scale` × the run's font size tall, its left edge on the pen and its +vertical centre on the middle of the cap height raised by `shift` points. Its advance is the scaled art width plus +the run's tracking, so it breaks like a word (no break between it and punctuation stuck to it) and justifies like +a glyph; a graphic taller than the font's ascent or descent opens up its line. Its art follows the symbol +(Redefine Symbol resizes it); a missing or deleted symbol leaves an empty slot one em square that draws nothing. +Its em box is its run's, whatever its scale: Character Alignment moves it with its run's text (it is one of +the line's largest characters only when its run's size is), and Top-to-Top leading spaces its line by its run's +em box (Roman leading by its run's leading). In right-to-left text it is a bidi neutral (U+FFFC): it takes the +direction of the text around it and the line's reordering places it. +The canvas draws the art inside the type's transparency (its opacity and blend mode apply). SVG keeps the text live +(the text after a graphic is positioned after it) and writes each graphic as a `` of the symbol's `` +def (or a copy of its art); type on a path with graphics is written as outlines. PDF draws the art as vector paths +where the layout puts it. + ## Flatten Transparency `object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in From 8ccafe11879698f125119ede0b80d72e9e1a6d42 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:53:30 +0700 Subject: [PATCH 49/74] Russian: Artboard and Exit for the Properties panel's artboard editing #570 added them while the Russian catalog was landing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ru.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a13a283c..d68d4502 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3604,3 +3604,5 @@ axis Both Оба Constrain Ограничить Free Transform Свободное трансформирование Perspective Distort Искажение перспективы + Artboard Монтажная область + Exit Выйти From d9b5eea18d04843f6092a8418e160561144ad005 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:53:57 -0600 Subject: [PATCH 50/74] Fix #536: the Fill/Stroke popover's Swatch Libraries button opens its menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the Fill or Stroke chip's popover (Control bar, Properties › Appearance), the Swatch Libraries Menu button at the foot of the swatches closed the popover and showed nothing, as in the video on #536; Show Swatch Kinds did the same. egui remembers one open popup per window: the chip's popover was such a remembered popup, so the button's menu, opening as another one, closed the popover and itself with it. The popover now keeps its own open state with widgets::popover (made for popovers that open menus of their own), so the libraries menu opens over it and a library chosen there opens in the library panel. Escape, a click outside and a click on the chip still close it. Test: tests_paintchips::the_popover_swatch_libraries_button_opens_the_libraries_menu (fails before this change: the popover closes). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW --- crates/ui-egui/src/panels/mod.rs | 7 ++-- crates/ui-egui/src/tests_paintchips.rs | 52 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 6c7fa771..2ce75db3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -346,10 +346,9 @@ pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, si app.run("paint.toggleActive", json!({ "fill": !stroke })).ok(); set_pstate(ui.ctx(), MIXER, ui.input(|i| i.modifiers.shift)); } - egui::Popup::from_toggle_button_response(&resp) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .width(POPOVER_WIDTH) - .show(|ui| paint_popover(app, ui)); + // A popover that keeps its own open state: the Swatches body opens menus of its own (Swatch + // Libraries, Show Swatch Kinds), which a remembered egui popup would close with them (#536). + crate::widgets::popover(&resp, resp.clicked(), |ui| paint_popover(app, ui)); } /// The chip popovers' width (a narrow Swatches or Color panel). diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs index 4ce5cf5b..b28ad83e 100644 --- a/crates/ui-egui/src/tests_paintchips.rs +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -270,3 +270,55 @@ fn the_transform_link_opens_the_transform_panel() { click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar); assert!(text_rect(&frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar), "Scale Corners").is_none()); } + +/// The 24 pt icon buttons of the popover's bottom bar (its Swatch Libraries Menu first), left to +/// right. +fn popover_bottom_buttons(ctx: &egui::Context) -> Vec { + let mut r: Vec = ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .filter(|(l, _)| l.order != egui::Order::Background) + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == vec2(24.0, 24.0)) + .map(|w| w.rect) + .collect() + }); + let bottom = r.iter().map(|r| r.top()).fold(f32::MIN, f32::max); + r.retain(|w| (w.top() - bottom).abs() < 1.0); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r +} + +/// A menu row's text (menu labels leave room for a check mark before them). +fn menu_item_rect(shapes: &[Shape], s: &str) -> Option { + shapes.iter().find_map(|sh| match sh { + Shape::Text(t) if t.galley.text().trim() == s => Some(t.visual_bounding_rect()), + Shape::Vec(v) => menu_item_rect(v, s), + _ => None, + }) +} + +/// #536: the Swatch Libraries Menu button at the foot of a Fill chip's popover opens the +/// libraries menu (it used to close the popover and show nothing), and a library chosen there +/// opens in the library panel. +#[test] +fn the_popover_swatch_libraries_button_opens_the_libraries_menu() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let ctx = context(); + frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + let (fill, _) = chips(&ctx); + click(&mut app, &ctx, fill.center(), Modifiers::NONE, control_bar); + let libraries = *popover_bottom_buttons(&ctx).first().expect("the popover has a bottom bar"); + click(&mut app, &ctx, libraries.center(), Modifiers::NONE, control_bar); + let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + assert!(text_rect(&shapes, "Swatch Tiles").is_some(), "the popover stays open"); + let defaults = menu_item_rect(&shapes, "Default Swatches").expect("the libraries menu is open"); + assert!(menu_item_rect(&shapes, "Other Library…").is_some()); + let earth = menu_item_rect(&shapes, "Earth Tones").expect("the menu lists the built-in libraries"); + assert!(earth.top() > defaults.top()); + click(&mut app, &ctx, earth.center(), Modifiers::NONE, control_bar); + let open = app.ui.library_panel.as_ref().map(|o| (o.kind.clone(), o.id.clone())); + assert_eq!(open, Some(("swatches".into(), "earth-tones".into()))); +} From a366e4ff79cde0f26729fee65a69b6d84c8a87bd Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:01:16 +0900 Subject: [PATCH 51/74] Type: the comments on new type's burasagari default state the default only Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 3 +-- crates/engine/src/cmd/typecmd.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 53c87235..89f9e12c 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -447,8 +447,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of /// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed -/// with Line-end Punctuation Half Width and Standard burasagari (as in Illustrator). Imported text -/// keeps its own. +/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own. pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6062781a..51dca2c3 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -629,7 +629,7 @@ mod area_tests { NodeKind::Text(t) => t.para.clone(), _ => panic!("text"), }; - // New type: Standard, as in Illustrator. + // New type: Standard. assert_eq!(para(&s).burasagari, Burasagari::Standard); s.execute("select.set", &json!({"ids": [id]})).unwrap(); assert!(s.execute("text.setFormat", &json!({"burasagari": "strong"})).is_err()); From 1ee36c2c0701b670714dff85bf2f8d56fa52e05d Mon Sep 17 00:00:00 2001 From: Diego Gomez Garcia Date: Fri, 9 Oct 2026 01:05:11 +0200 Subject: [PATCH 52/74] Fix pasted type losing its layout bounds cache Type pasted or imported from another app (Illustrator, PDF, SVG) arrives without cached_bounds, which is not serialized. TextObject::bounds() then falls back to estimate_bounds(), a rough 0.55 em per character, so the selection box came up short of the last character and alignment looked off until the file was saved and reopened (Session::add_document refreshes it). Refresh the bounds of pasted text in paste_clip, covering paste and paste in front/back/in place and every clipboard flavour, with a regression test. --- crates/engine/src/cmd/clipboard.rs | 20 ++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 3 +++ crates/engine/src/cmd/typecmd.rs | 14 ++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 50b81bfe..63f8af70 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -237,6 +237,26 @@ mod tests { assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6); } + #[test] + fn pasted_type_gets_its_layout_bounds_cache() { + // Reported from Illustrator: pasted type kept no layout bounds, so its box (and alignment) + // fell back to the rough estimate until the file was saved and reopened. + let mut s = session(); + let svg = r#"WWWWWWWW"#; + s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let mut checked = 0; + s.doc().unwrap().doc.walk(|n| { + let vectorcraft_doc::NodeKind::Text(t) = &n.kind else { return }; + let cached = t.cached_bounds.expect("pasted type computed its bounds"); + let real = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + assert!((cached.width() - real.width()).abs() < 1e-6, "{cached:?} {real:?}"); + assert!(cached.width() > t.estimate_bounds().width() + 1.0, "{cached:?} is just the estimate"); + checked += 1; + }); + assert_eq!(checked, 1, "one text object pasted"); + } + #[test] fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() { // Found by the model-based test: undo restores the id counter, so a compound path made diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e601717a..8f2b5cec 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -364,6 +364,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho } } sel.set(new_ids.iter().copied()); + // Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are + // right away (opening the file later would, but paste must not depend on a reopen). + super::typecmd::refresh_bounds_of(d, &new_ids); Ok((new_ids, imported)) })?; Ok(json!({ diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f0cb2ff3..8935b325 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -103,6 +103,20 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { t.cached_bounds = Some(lay.bounds); } +/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks +/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the +/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough +/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off. +pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) { + for id in text_ids(d, ids) { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && t.cached_bounds.is_none() + { + refresh_bounds(t); + } + } +} + /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![]; From 4411688b3d26936c1affd14ecb85f3153a144164 Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:14:17 +0900 Subject: [PATCH 53/74] Type: Character Alignment on the ICF top and bottom (ideographic character face) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character panel menu › Character Alignment gains ICF Top/Right and ICF Bottom/Left, and `charAlign` takes `icfTop` / `icfBottom`: a character smaller than the largest on its line lines up its ideographic character face's top (right, vertical) or bottom (left) with the big one's. `FontFace::icf_margins` places the ICF inside the em box as the OpenType baseline tags define it (https://learn.microsoft.com/en-us/typography/opentype/spec/baselinetags): `icfb` / `icft` of the BASE table's horizontal and vertical axes against `ideo` / `idtp` (else the OS/2 typographic descender and one em), for Han ideographs (else kana, else the first script). Without them, as that page allows, the average ink box of some ideographs and kana; a face without ideographs uses its em box. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/text.rs | 7 +- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/textedit.rs | 13 +++- crates/engine/src/cmd/typecmd.rs | 6 ++ crates/text/src/fontdb.rs | 95 ++++++++++++++++++++++++++ crates/text/src/layout.rs | 12 ++-- crates/text/src/lib.rs | 3 +- crates/text/src/tests.rs | 36 ++++++++++ crates/ui-egui/src/i18n/es.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 | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/character.rs | 2 + docs/mcp.md | 13 ++++ 19 files changed, 195 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9272d159..46fd3b09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,7 +141,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. 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). -- **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** @@ -213,7 +213,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | 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), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), 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 | 20–28 | +| 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), kinsoku sets 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 | 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is 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 presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fa19cb5a..c52fd0e7 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -93,8 +93,9 @@ pub struct CharStyle { } /// Where a character smaller than the largest on its line lines up with it: on the Roman -/// baseline, or at the top (right, in vertical type), centre or bottom (left) of the ideographic -/// em boxes. +/// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic +/// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the +/// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum CharAlign { @@ -103,6 +104,8 @@ pub enum CharAlign { EmBoxTop, EmBoxCenter, EmBoxBottom, + IcfTop, + IcfBottom, } /// Superscript or subscript proportions in percent of the font size (Document Setup → Type). diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 53c87235..0ea764bd 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -58,7 +58,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 852d6e7a..fa64b596 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index bd95964f..b4ca0078 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"} style a character range (runs are split at the range ends) → {id, runs}", + "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -198,7 +198,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags())) } -/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"` (Character Alignment). +/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment). pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result> { use vectorcraft_doc::CharAlign; let Some(v) = p.get("charAlign").filter(|v| !v.is_null()) else { return Ok(None) }; @@ -207,7 +207,14 @@ pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result CharAlign::EmBoxTop, Some("emBoxCenter") => CharAlign::EmBoxCenter, Some("emBoxBottom") => CharAlign::EmBoxBottom, - _ => return Err(bad(cmd, "`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\" or \"emBoxBottom\"")), + Some("icfTop") => CharAlign::IcfTop, + Some("icfBottom") => CharAlign::IcfBottom, + _ => { + return Err(bad( + cmd, + "`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\", \"emBoxBottom\", \"icfTop\" or \"icfBottom\"", + )); + } })) } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6062781a..3c53fd11 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -766,6 +766,12 @@ mod area_tests { s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "charAlign": "emBoxTop"})).unwrap(); let aligns: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.char_align)).collect(); assert_eq!(aligns, [("雅".to_string(), CharAlign::RomanBaseline), ("楽".to_string(), CharAlign::EmBoxTop)]); + // The ideographic character face's top and bottom. + for (key, want) in [("icfTop", CharAlign::IcfTop), ("icfBottom", CharAlign::IcfBottom)] { + s.execute("text.setFormat", &json!({"charAlign": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_align == want)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["charAlign"], key); + } } #[test] diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 38f7663b..5001afd1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -79,6 +79,19 @@ pub struct FontFace { pub(crate) instance: Option, /// [`Self::ideographic_centre`], read once: layout asks for it per glyph. ideographic_centre: std::sync::OnceLock, + /// [`Self::icf_margins`], read once. + icf_margins: std::sync::OnceLock, +} + +/// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance +/// from each edge of the em box, in ems. `top` and `bottom` along a horizontal line, `right` and +/// `left` across a vertical one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct IcfMargins { + pub top: f64, + pub bottom: f64, + pub right: f64, + pub left: f64, } impl std::fmt::Debug for FontFace { @@ -177,6 +190,69 @@ impl FontFace { gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem) }) } + /// The ideographic character face as margins inside the em box ([`IcfMargins`]): from the + /// font's BASE table as the OpenType baseline tags define it (`icfb`, `icft` against `ideo`, + /// `idtp`), else, as that definition allows, from the bounds of some ideographs and kana + /// averaged, else none (the ICF is the em box). + /// + pub fn icf_margins(&self) -> IcfMargins { + *self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default()) + } + + /// [`Self::icf_margins`] from the BASE table: the ICF's bottom edge `icfb` (top edge `icft`, else + /// as far below the em box top) against the em box `ideo` .. `idtp` (else the OS/2 typographic + /// descender, and one em above it); across vertical lines, `icfb` and `icft` of the vertical + /// axis against 0 .. `idtp` (else the horizontal margin, and one em). + pub(crate) fn icf_from_base(&self) -> Option { + use skrifa::raw::TableProvider; + let f = self.skrifa()?; + let base = f.base().ok()?; + let em = self.upem; + let h = base_values(base.horiz_axis()?.ok()?)?; + let icfb = *h.get(b"icfb")?; + let em_bottom = match h.get(b"ideo") { + Some(v) => *v, + None => f64::from(f.os2().ok()?.s_typo_descender()), + }; + let em_top = h.get(b"idtp").copied().unwrap_or(em_bottom + em); + let margin = icfb - em_bottom; + let icf_top = h.get(b"icft").copied().unwrap_or(em_top - margin); + let v = base.vert_axis().and_then(Result::ok).and_then(base_values).unwrap_or_default(); + let icf_left = v.get(b"icfb").copied().unwrap_or(margin); + let em_right = v.get(b"idtp").copied().unwrap_or(em); + let icf_right = v.get(b"icft").copied().unwrap_or(em_right - icf_left); + let m = IcfMargins { top: (em_top - icf_top) / em, bottom: margin / em, right: (em_right - icf_right) / em, left: icf_left / em }; + // Inside the em box, short of its middle: anything else is a damaged table. + [m.top, m.bottom, m.right, m.left].iter().all(|x| (0.0..0.5).contains(x)).then_some(m) + } + + /// [`Self::icf_margins`] from the ink of some ideographs and kana: each edge's margin to the em + /// box (its centre [`Self::ideographic_centre`], one em high and wide), averaged. + pub(crate) fn icf_from_glyphs(&self) -> Option { + let f = self.skrifa()?; + let metrics = f.glyph_metrics(Size::unscaled(), self.location()); + let em = self.upem; + let (em_bottom, em_top) = ((self.ideographic_centre() - 0.5) * em, (self.ideographic_centre() + 0.5) * em); + let boxes: Vec<_> = ['国', '東', '永', '書', 'あ', 'ア'] + .into_iter() + .map(|c| self.glyph_for(c)) + .filter(|g| *g != 0) + .filter_map(|g| metrics.bounds(GlyphId::new(g))) + .collect(); + if boxes.is_empty() { + return None; + } + let n = boxes.len() as f64; + let mean = |v: &dyn Fn(&skrifa::metrics::BoundingBox) -> f32| boxes.iter().map(|b| f64::from(v(b))).sum::() / n; + let m = IcfMargins { + top: (em_top - mean(&|b| b.y_max)) / em, + bottom: (mean(&|b| b.y_min) - em_bottom) / em, + right: (em - mean(&|b| b.x_max)) / em, + left: mean(&|b| b.x_min) / em, + }; + [m.top, m.bottom, m.right, m.left].iter().all(|x| (0.0..0.5).contains(x)).then_some(m) + } + /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -926,9 +1002,28 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option) -> Option> { + let tags = axis.base_tag_list()?.ok()?; + let list = axis.base_script_list().ok()?; + let records = list.base_script_records(); + let record = + [b"hani", b"kana"].into_iter().find_map(|t| records.iter().find(|r| r.base_script_tag() == Tag::new(t))).or_else(|| records.first())?; + let values = record.base_script(list.offset_data()).ok()?.base_values()?.ok()?; + let mut out = HashMap::new(); + for (tag, coord) in tags.baseline_tags().iter().zip(values.base_coords().iter()) { + if let Ok(c) = coord { + out.insert(tag.get().to_be_bytes(), f64::from(c.coordinate())); + } + } + Some(out) +} + /// `s` lowercased, without anything but letters and digits. fn norm_chars(s: &str) -> impl Iterator + '_ { s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 83bd52f8..d8824700 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -412,15 +412,19 @@ fn glyph_em(g: &SGlyph) -> f64 { /// How far up (line space) Character Alignment `a` moves glyph `g` on a line whose largest em is /// `line_em`: the glyph's em box top, centre or bottom onto the line's (the em box running from -/// its centre less half an em to its centre plus half an em above the baseline). Nothing on the -/// Roman baseline, or for the line's largest characters. -fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64) -> f64 { +/// its centre less half an em to its centre plus half an em above the baseline), or its ICF's top +/// or bottom (the em box's less the face's [`crate::IcfMargins`]; across a vertical line, its +/// right and left). Nothing on the Roman baseline, or for the line's largest characters. +fn align_shift(g: &SGlyph, a: vectorcraft_doc::CharAlign, line_em: f64, vertical: bool) -> f64 { use vectorcraft_doc::CharAlign; + let icf = || g.face.icf_margins(); let k = match a { CharAlign::RomanBaseline => return 0.0, CharAlign::EmBoxTop => 0.5, CharAlign::EmBoxCenter => 0.0, CharAlign::EmBoxBottom => -0.5, + CharAlign::IcfTop => 0.5 - if vertical { icf().right } else { icf().top }, + CharAlign::IcfBottom => -0.5 + if vertical { icf().left } else { icf().bottom }, }; (g.face.ideographic_centre() + k) * (line_em - glyph_em(g)).max(0.0) } @@ -1508,7 +1512,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio adv += per_gap; } } - let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em); + let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li); x += adv; if j < trimmed { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 69919b17..50bab924 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -25,7 +25,8 @@ pub mod thread; pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ - FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, PlatformFontFiles, set_platform_font_files, style_weight, system_font_dirs, + FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, style_weight, + system_font_dirs, }; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c67c6259..e6e84fff 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -647,6 +647,42 @@ fn character_alignment_lines_small_characters_up_with_the_largest_em_box() { } } +/// Character Alignment on the ICF: a 20 pt character next to a 40 pt one lines its ideographic +/// character face's top (right, vertical) or bottom (left) up with the big one's, which lies inside +/// the em box by the face's ICF margins. A face without ideographs has none (the ICF is the em +/// box). The Japanese faces need craft-fonts (skipped without them). +#[test] +fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_face() { + use vectorcraft_doc::{CharAlign, TextRun}; + let place = |family: &str, a: CharAlign, vertical_type: bool| { + let st = |size: f64| CharStyle { font_family: family.into(), ..style(size) }; + let mut t = point("", st(40.0)); + t.vertical = vertical_type; + t.runs = vec![TextRun { text: "大".into(), style: st(40.0) }, TextRun { text: "小".into(), style: CharStyle { char_align: a, ..st(20.0) } }]; + let l = layout(db(), &t); + let (big, small) = (l.glyphs[0].origin, l.glyphs[1].origin); + if vertical_type { small.x - big.x } else { big.y - small.y } + }; + let near = |a: f64, b: f64| (a - b).abs() < 0.01; + // No ideographs: the ICF is the em box. + assert_eq!(db().face("Source Sans 3", "Regular").unwrap().icf_margins(), crate::IcfMargins::default()); + let Some(face) = db().face("Shippori Mincho", "Regular").filter(|f| f.family == "Shippori Mincho") else { + return; // no Japanese font here + }; + let m = face.icf_margins(); + assert!([m.top, m.bottom, m.right, m.left].iter().all(|x| *x > 0.01 && *x < 0.2), "{m:?}"); + for vertical_type in [false, true] { + let (top, bottom) = if vertical_type { (m.right, m.left) } else { (m.top, m.bottom) }; + // The big character's ICF top is `top` ems below its em box top: 20 pt of difference. + let em_top = place("Shippori Mincho", CharAlign::EmBoxTop, vertical_type); + let icf_top = place("Shippori Mincho", CharAlign::IcfTop, vertical_type); + assert!(near(em_top - icf_top, top * 20.0), "vertical {vertical_type}: {em_top} {icf_top} {m:?}"); + let em_bottom = place("Shippori Mincho", CharAlign::EmBoxBottom, vertical_type); + let icf_bottom = place("Shippori Mincho", CharAlign::IcfBottom, vertical_type); + assert!(near(icf_bottom - em_bottom, bottom * 20.0), "vertical {vertical_type}: {em_bottom} {icf_bottom} {m:?}"); + } +} + /// Burasagari leaves Latin punctuation alone: in a measure of exactly "abcd", the full stop of /// "abcd. ef" doesn't hang with Standard or Forced (the line breaks as it does with None), and a /// Latin line ending in a full stop isn't shortened by Forced. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a1383173..b3425adc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -773,6 +773,8 @@ Em Box Bottom/Left Abajo/izquierda del cuadro eme Em Box Center Centro del cuadro eme Em Box Top/Right Arriba/derecha del cuadro eme + ICF Top/Right Arriba/derecha del ICF + ICF Bottom/Left Abajo/izquierda del ICF Em Dash Raya Em Space Espacio eme Embed Incrustar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dae0112f..ea67f329 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -773,6 +773,8 @@ Em Box Bottom/Left Riquadro em in basso/a sinistra Em Box Center Riquadro em al centro Em Box Top/Right Riquadro em in alto/a destra + ICF Top/Right ICF in alto/a destra + ICF Bottom/Left ICF in basso/a sinistra Em Dash Lineetta Em Space Spazio em Embed Incorpora diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d1a25795..fff55f0f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -29,6 +29,8 @@ Em Box Bottom/Left 仮想ボディの下 / 左 Em Box Center 仮想ボディの中央 Em Box Top/Right 仮想ボディの上 / 右 + ICF Top/Right 平均字面の上 / 右 + ICF Bottom/Left 平均字面の下 / 左 Favorites お気に入り Flip 反転 Japanese 日本語 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5aac4081..5e9d3433 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -730,6 +730,8 @@ Em Box Bottom/Left Base/Esquerda da Caixa Eme Em Box Center Centro da Caixa Eme Em Box Top/Right Topo/Direita da Caixa Eme + ICF Top/Right Topo/Direita do ICF + ICF Bottom/Left Base/Esquerda do ICF Em Dash Travessão Em Space Espaço Em Embed Incorporar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d68d4502..1d0a9155 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -771,6 +771,8 @@ Em Box Bottom/Left Низ/лево кегельной площадки Em Box Center Центр кегельной площадки Em Box Top/Right Верх/право кегельной площадки + ICF Top/Right Верх/право ICF + ICF Bottom/Left Низ/лево ICF Em Dash Длинное тире Em Space Круглая шпация Embed Внедрить diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d34bc3af..5ae884ca 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -768,6 +768,8 @@ Em Box Bottom/Left 全角字框下 / 左 Em Box Center 全角字框居中 Em Box Top/Right 全角字框上 / 右 + ICF Top/Right ICF 上 / 右 + ICF Bottom/Left ICF 下 / 左 Em Dash 长破折号 Em Space 全角空格 Embed 嵌入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5ae4c48e..5779b389 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -788,6 +788,8 @@ Em Box Bottom/Left 全形字框下 / 左 Em Box Center 全形字框中央 Em Box Top/Right 全形字框上 / 右 + ICF Top/Right ICF 上 / 右 + ICF Bottom/Left ICF 下 / 左 Em Dash 長破折號 Em Space 全形空格 Embed 嵌入 diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index ef44d15b..e67c9181 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -513,6 +513,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { (tl!("Em Box Top/Right"), CharAlign::EmBoxTop, "emBoxTop"), (tl!("Em Box Center"), CharAlign::EmBoxCenter, "emBoxCenter"), (tl!("Em Box Bottom/Left"), CharAlign::EmBoxBottom, "emBoxBottom"), + (tl!("ICF Top/Right"), CharAlign::IcfTop, "icfTop"), + (tl!("ICF Bottom/Left"), CharAlign::IcfBottom, "icfBottom"), ] { if menu_item(ui, label, true, align == Some(a)) { format(app, json!({"charAlign": key})); diff --git a/docs/mcp.md b/docs/mcp.md index 8f4dbeac..f974b736 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2274,6 +2274,19 @@ undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}} ``` +## Character Alignment + +`text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range) +set where characters smaller than the largest on their line line up with it: `romanBaseline` (the default), the em box's +`emBoxTop`, `emBoxCenter` or `emBoxBottom`, or the ideographic character face's (ICF) `icfTop` or `icfBottom`. In +vertical type top and bottom are the right and left of the column. The ICF comes from the font's BASE table (`icfb`, +`icft`, as the OpenType baseline tags define them), else from the average ink box of some ideographs and kana; a font +without ideographs uses its em box. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}} +``` + ## New type in a Japanese interface While the interface is in Japanese (VectorCraft › Language, or `auto` on a Japanese system), new type starts with em From 9ec940222bb2ee0ff2b1b9c6186be973f0ea53f5 Mon Sep 17 00:00:00 2001 From: Simon Leblanc Date: Fri, 9 Oct 2026 00:00:03 +0200 Subject: [PATCH 54/74] Add French (fr) interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fr.tsv with the full interface coverage: every menu string, every tl! literal, the plurals and the status/error messages (@msg). Registered as complete_menus with the French plural rule (0 and 1 are singular); every fr-* locale (fr-FR, fr-BE, fr-CA, fr-CH …) resolves to it. Keeps the product, workspace and perspective preset names in English like the other complete catalogs. Tests: french_reads_as_french; fr added to KEEPS_MENU_NAMES and COMPLETE_MESSAGES; the Latin-script glyph test covers French letters and the no-break space; locale tests use de-DE as the unsupported case. Clean-room: written from the meaning of the English labels in the vocabulary French-speaking users of vector illustration software know (Plan de travail, Nuancier, Contour, Fond, Tracé, Calque). No other application's localisation was copied. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- ROADMAP.md | 4 +- crates/ui-egui/src/i18n/fr.tsv | 3607 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 10 +- crates/ui-egui/src/i18n/tests.rs | 44 +- docs/development.md | 7 +- 6 files changed, 3660 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/i18n/fr.tsv diff --git a/README.md b/README.md index 109e2532..984a7bf0 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ milestones, and honest time-to-parity estimates. "a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface -speaks English, Japanese, Traditional and Simplified Chinese, Spanish, Italian and Russian (and Czech and Brazilian Portuguese in the menus). +speaks English, Japanese, Traditional and Simplified Chinese, Spanish, Italian, Russian and French (and Czech and Brazilian Portuguese in the menus). The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index f6202417..5defa292 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, Italian, Russian and French ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). @@ -141,7 +141,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). -- **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), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian and Russian 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, Italian and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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), Italian (every `it-*` locale), Russian (every `ru-*` locale) and French (every `fr-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian, Russian and French 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, Italian, French and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv new file mode 100644 index 00000000..74b3434f --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -0,0 +1,3607 @@ +# French (fr) catalog. Same format as zh-hant.tsv: context source translation +# (empty context = a plain UI string; `@id` = a command id; `@plural` = "one|other" forms). +# +# Clean-room: written from the meaning of VectorCraft's English labels in standard French, using the +# feature names French-speaking users of vector illustration software know (Plan de travail, Nuancier, +# Contour, Fond, Tracé, Point d'ancrage, Plume, Sélection directe, Pathfinder, Motif, Paramètre +# prédéfini, Calque). No other application's French localisation was copied. Labels use sentence case +# (only the first word capitalised); commands use the infinitive (Ouvrir, Enregistrer, Afficher) and +# sentences address the user as "vous". French typography: a no-break space before : ; ? ! and inside +# « », the typographic apostrophe ’. Shortcut keys use their French key-cap names (Maj, Ctrl, Alt, Échap). +# +# 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 +# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. +# Status and error messages too (`@msg` rows at the end, `COMPLETE_MESSAGES` in the tests). +# A native speaker's review is welcome: keep the placeholders ({name}), the trailing "…" and the +# leading/trailing spaces of fragments exactly as they are. + + — missing — manquant + to search every command pour rechercher toutes les commandes + (closest) (le plus proche) + + Add Scale + Ajouter une échelle + 0.2 (quality) to 100 (speed) de 0,2 (qualité) à 100 (vitesse) + 1 Color Job… Travail en 1 couleur… + 1 {unit} on the artboard stands for {real} {unit} in the scene. 1 {unit} sur le plan de travail représente {real} {unit} dans la scène. + 2 Color Job… Travail en 2 couleurs… + 3 Color Job… Travail en 3 couleurs… + 30° Angles Angles de 30° + 3D and Materials 3D et matières + 3D Ribbon Ruban 3D + 45° Angles Angles de 45° + 60° Angles Angles de 60° + 90° & 45° & 30° Angles Angles de 90°, 45° et 30° + 90° & 45° Angles Angles de 90° et 45° + 90° Angles Angles de 90° + < 1 sec < 1 s + + [Default] is protected: New… starts an editable copy. [Par défaut] est protégé : Nouveau… crée une copie modifiable. + [Unnamed] [Sans nom] + A File… Un fichier… + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. Une valeur faible suit le contour de près ; une valeur élevée donne moins de points et des courbes plus lisses. + A new preset starting from the selected one Un nouveau paramètre prédéfini basé sur celui sélectionné + A saved copy of the selected preset Une copie enregistrée du paramètre prédéfini sélectionné + About VectorCraft À propos de VectorCraft + above au-dessus + Absolute Position Position absolue + Accurate Précis + Abutting Contigu + Action {n} Action {n} + Actions Actions + Activate Document Activer le document + Active Perspective Plane Plan de perspective actif + Actual Size Taille réelle + Add Ajouter + Add Anchor (Pen) Ajouter un point (Plume) + Add Anchor Point Ajouter un point d’ancrage + Add Anchor Point Tool Outil Ajout de point d’ancrage + Add Anchor Points Ajouter des points d’ancrage + Add Effect Ajouter un effet + Add Freeform Line Ajouter une ligne libre + Add Freeform Point Ajouter un point libre + Add Guide Ajouter un repère + Add Mesh Line Ajouter une ligne de filet + Add New Ajouter un nouvel élément + Add New Effect Ajouter un nouvel effet + Add New Fill Ajouter un nouveau fond + Add New Stroke Ajouter un nouveau contour + Add Selected Artwork (Alt: as one asset) Ajouter l’illustration sélectionnée (Alt : en une seule ressource) + Add Selected Artwork as Multiple Assets Ajouter l’illustration sélectionnée en plusieurs ressources + Add Selected Artwork as One Asset Ajouter l’illustration sélectionnée en une seule ressource + Add Spine Point Ajouter un point à la dorsale + Add Swatches Ajouter des nuances + Add to Graphic Styles Ajouter aux styles graphiques + Add to Profiles Ajouter aux profils + Add to Profiles… Ajouter aux profils… + Add to Selection Ajouter à la sélection + Add to Swatches Ajouter au nuancier + Add Used Colors Ajouter les couleurs utilisées + Additional Plug-ins Folder Dossier de modules externes supplémentaires + Adjust Adjoining Width Points Ajuster les points de largeur adjacents + Adjust Color Balance… Régler la balance des couleurs… + Adjust Colors Régler les couleurs + Adjust Options: Options de réglage : + Advanced Avancé + Advanced Options Options avancées + Align Aligner + Align center Centrer + Align left Aligner à gauche + Align Objects: Aligner les objets : + Align On: Aligner sur : + Align right Aligner à droite + Align Stroke to Center Aligner le contour au centre + Align Stroke to Inside Aligner le contour à l’intérieur + Align Stroke to Outside Aligner le contour à l’extérieur + Align Stroke: Aligner le contour : + Align to Artboard Aligner sur le plan de travail + Align to Glyph Bounds Aligner sur les limites du glyphe + Align to Key Object Aligner sur l’objet clé + Align to Page Aligner sur la page + Align to Path Aligner sur le tracé + Align to Selection Aligner sur la sélection + Align To: Aligner par rapport à : + Align: Aligner : + Alignment Guides Repères d’alignement + Alignment: Alignement : + All Tout + All Artwork Above Toute l’illustration au-dessus + All Caps Tout en capitales + All Categories Toutes les catégories + All colours are within the CMYK gamut Toutes les couleurs sont dans la gamme CMJN + All on Active Artboard Tout sur le plan de travail actif + All on Same Layers Tout sur les mêmes calques + All printer's marks Tous les repères d’impression + All Scripts Toutes les écritures + All Text Objects Tous les objets texte + All the art Toute l’illustration + All Tools Tous les outils + Alt Alt + Alt: Alt : + Alter Paths for Appearance Modifier les tracés selon l’aspect + Amount Quantité + Amount: Quantité : + Anchor Point Point d’ancrage + Anchor Point Tool Outil Point d’ancrage + Anchor Points Points d’ancrage + Anchor Points, Handle, and Bounding Box Display Affichage des points d’ancrage, des poignées et du cadre de sélection + Anchor/Path Labels Libellés de point d’ancrage/tracé + Anchors: Points d’ancrage : + Angle Angle + Angle: Angle : + Angles Angles + Animated Zoom Zoom animé + Anti-alias Lissage + Anti-Alias Lissage + Anti-alias Rasters Lisser les images pixellisées + Anti-aliased Artwork Illustration lissée + Anti-aliasing: Lissage : + Any Kind Tout type + Appearance Aspect + Appearance Attribute Attribut d’aspect + Appearance Item Élément d’aspect + Appearance of Black Aspect du noir + Appearance Panel Panneau Aspect + 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 Brush Appliquer la forme + Apply Character Style Appliquer le style de caractère + Apply Effect Appliquer l’effet + Apply Graphic Style Appliquer le style graphique + Apply Last Color Appliquer la dernière couleur + Apply Last Effect Appliquer le dernier effet + Apply Last Gradient Appliquer le dernier dégradé + Apply Paragraph Style Appliquer le style de paragraphe + Apply SVG Filter… Appliquer un filtre SVG… + Apply to All Appliquer à tous + Apply to All ({count} more missing) Appliquer à tous ({count} autres manquants) + Arc Arc + Arc Lower… Arc inférieur… + Arc Tool Outil Arc + Arc Upper… Arc supérieur… + Arch… Arche… + Arc… Arc… + Area / Path Type Texte captif / curviligne + Area Graph Tool Outil Graphe en aires + Area Text Objects Objets texte captif + Area Type Options Options de texte captif + Area Type Options… Options de texte captif… + Area Type Tool Outil Texte captif + Around Object Autour de l’objet + Arrange Disposition + Arrange: Bring to Front Mettre au premier plan + Arrowheads: Pointes de flèche : + Art Illustration + Art using a deleted global swatch keeps its colour. L’illustration qui utilise une nuance globale supprimée conserve sa couleur. + Artboard Options Options du plan de travail + Artboard Options: edit with the Artboard tool Options du plan de travail : modifier avec l’outil Plan de travail + Artboard Options… Options du plan de travail… + Artboard Tool Outil Plan de travail + Artboard {number} Plan de travail {number} + Artboard: Plan de travail : + Artboards Plans de travail + ArtCraft Website Site web d’ArtCraft + ArtCraft website Site web d’ArtCraft + Artwork Colors Couleurs de l’illustration + As Multiple Assets En plusieurs ressources + As Single Asset En une seule ressource + Ascender Jambage ascendant + Ask When Modified Demander en cas de modification + Aspect Ratio Proportions + Asset Export Exportation de ressources + Asset Export Settings Paramètres d’exportation de ressources + Assets Ressources + Assign Attribuer + Assign Profile… Attribuer un profil… + Assign: Attribuer : + Attach to Active Plane Associer au plan actif + Attributes Attributs + Author Title: Fonction de l’auteur : + Author: Auteur : + Auto Auto + Automatic Automatique + 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 + Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail) + Automatic flatness Planéité automatique + Automatically Automatiquement + Automatically Save Recovery Data Enregistrer automatiquement les données de récupération + Average Moyenne + Average… Moyenne… + Axis: Axe : + Background Color: Couleur d’arrière-plan : + Background Contents Contenu de l’arrière-plan + Background Contents: Contenu de l’arrière-plan : + Background: Arrière-plan : + Backspace Retour arrière + Bar Graph Tool Outil Graphe en barres + Baseline Ligne de base + Baseline Shift Décalage de la ligne de base + Basic Latin Latin de base + Batch Traitement par lots + Begin Recording Démarrer l’enregistrement + Bend: Courbure : + Bevel Join Raccord biseauté + Bitmap Bitmap + Black Noir + Black and White Noir et blanc + Black and White Logo Logo noir et blanc + BLANK DOCUMENT PRESETS PARAMÈTRES PRÉDÉFINIS DE DOCUMENT VIERGE + Bleed Fond perdu + Bleed: Fond perdu : + Bleeds Fonds perdus + Blend Dégradé de formes + Blend Front to Back Fusionner de l’avant vers l’arrière + Blend Horizontally Fusionner horizontalement + Blend Info Infos sur le dégradé de formes + Blend Options Options de dégradé de formes + Blend Options… Options de dégradé de formes… + Blend Tool Outil Dégradé de formes + Blend Vertically Fusionner verticalement + Blending Mode Mode de fusion + Blends and Transparency Dégradés de formes et transparence + Bloat Tool Outil Dilatation + Blob Brush Forme de tache + Blob Brush Tool Outil Forme de tache + Blue Bleu + Blur Flou + Bottom Bas + Bottom in Front Bas au premier plan + Bottom: Bas : + Bounds Limites + Bounds (Fill Proportionally) Limites (remplir proportionnellement) + Bounds (Fit Proportionally) Limites (ajuster proportionnellement) + Break Link to Graphic Style Rompre le lien avec le style graphique + Break Link to Symbol Rompre le lien avec le symbole + Brick by Column Brique par colonne + Brick by Row Brique par rangée + Brick Offset: Décalage des briques : + Brightness Luminosité + Brightness/Contrast… Luminosité/contraste… + Brights Vives + Bring Forward En avant + Bring to Front Premier plan + Bristle Soies + Bristle Brush Strokes Contours de forme Soies + Browser Navigateur + Brush Affects: Le pinceau affecte : + Brush Definition Définition de la forme + Brush Libraries Bibliothèques de formes + Brush Libraries (on the roadmap) Bibliothèques de formes (prévu dans la feuille de route) + Brush Options… Options de forme… + Brush Strokes Contours de forme + Brushes Formes + Built-in Libraries Bibliothèques intégrées + Built-in presets are read-only: New… starts an editable copy. Les paramètres prédéfinis intégrés sont en lecture seule : Nouveau… crée une copie modifiable. + Built-in presets don't change: New makes a copy you can edit. Les paramètres prédéfinis intégrés ne changent pas : Nouveau crée une copie que vous pouvez modifier. + Built-in presets don't change: New… starts an editable copy. Les paramètres prédéfinis intégrés ne changent pas : Nouveau… crée une copie modifiable. + Bulge… Renflement… + Bullet Puce + Butt Cap Extrémité plate + Byte Order: Ordre des octets : + Calligraphic Calligraphique + Can't print these settings: see the Summary Impossible d’imprimer avec ces paramètres : voir le Résumé + Cancel Annuler + Cancel Key Object Annuler l’objet clé + Canvas Color Couleur de la zone de travail + Cap Extrémité + Cap: Extrémité : + Cascade Cascade + Center Centre + Center Artwork Centrer l’illustration + Center on Artboard Centrer sur le plan de travail + Center-Justified Tab Tabulation centrée + Centimeters Centimètres + Change Modifier + Change All Tout modifier + Change Case Modifier la casse + Change Screen Mode (F) Changer de mode d’affichage (F) + Change to Global Rulers Passer aux règles globales + Change to Right-to-Left Layout Passer à la disposition de droite à gauche + Changed since the last Refresh. Modifié depuis la dernière actualisation. + Changes allowed: Modifications autorisées : + Changes made since it was last saved will be lost. Les modifications apportées depuis le dernier enregistrement seront perdues. + Changing these settings changes how raster effects look when exported, flattened or expanded. Modifier ces paramètres change l’apparence des effets pixellisés lors de l’exportation, de l’aplatissement ou de la décomposition. + Channel Couche + Character Caractère + Character Alignment Alignement des caractères + Character / Paragraph Caractère / Paragraphe + Character Rotation Rotation des caractères + Character Set: Jeu de caractères : + Character Style Style de caractère + Character Style Options Options de style de caractère + Character Styles Styles de caractère + Check Links Vérifier les liens + Check Spelling… Vérifier l’orthographe… + Choose a folder Choisissez un dossier + Choose… Choisir… + Circle Cercle + Classic Defaults Valeurs par défaut classiques + Clean Up… Nettoyer… + Clear Effacer + Clear All Tabs Effacer toutes les tabulations + Clear Appearance Effacer l’aspect + Clear Guides Effacer les repères + Clear Overrides Effacer les remplacements + Clear Recent Files Effacer les fichiers récents + Click Clic + Click an anchor point Cliquez sur un point d’ancrage + Click or drag Cliquez ou faites glisser + Click the art with the Gradient tool to add or select points. Cliquez sur l’illustration avec l’outil Dégradé pour ajouter ou sélectionner des points. + Click the first point Cliquez sur le premier point + Click the ramp or a type button to apply a gradient. Cliquez sur la rampe de dégradé ou sur un bouton de type pour appliquer un dégradé. + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Cliquez pour ajouter une tabulation, faites-la glisser pour la déplacer, faites-la glisser hors de la règle pour la supprimer + Click to choose a swatch, Shift-click to mix a colour Cliquez pour choisir une nuance, Maj+clic pour mélanger une couleur + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Cliquez pour modifier le masque ; Alt+clic pour n’afficher que le masque ; Maj+clic pour le désactiver ou l’activer + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Cliquez pour placer chaque fichier à 100 %, ou faites glisser pour le dimensionner ; les flèches changent de fichier, Échap en ignore un. + Click to select, double-click to edit Cliquez pour sélectionner, double-cliquez pour modifier + Click to toggle visibility Cliquez pour afficher ou masquer + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Cliquez pour zoomer, Alt+clic pour dézoomer, faites glisser pour déplacer la vue, double-cliquez pour ajuster + Client-side (.html) Côté client (.html) + Clip Écrêtage + Clip Complex Regions Écrêter les zones complexes + Clip to Art Écrêter à l’illustration + Clip to Artboard Écrêter au plan de travail + Clip to Bounding Box Écrêter au cadre de sélection + Clipboard as PDF Presse-papiers en PDF + Clipboard as PNG Presse-papiers en PNG + Clipboard as SVG Presse-papiers en SVG + Clipboard as Text Presse-papiers en texte + Clipboard Formats Formats du presse-papiers + Clipboard Handling Gestion du presse-papiers + Clipping Mask Masque d’écrêtage + Clipping Masks Masques d’écrêtage + Close Fermer + Close All Tout fermer + Close Library Fermer la bibliothèque + Close Path Fermer le tracé + Close paths when ends are within: Fermer les tracés lorsque les extrémités sont à moins de : + Cmd Cmd + CMYK CMJN + CMYK Color Couleur CMJN + CMYK colours print with their own values; off, they go through the colour settings too. Les couleurs CMJN s’impriment avec leurs propres valeurs ; si l’option est désactivée, elles passent aussi par les paramètres de couleur. + Collapse Panels to Icons Réduire les panneaux en icônes + Collapse to Icons Réduire en icônes + Collate Assembler + Collect for Export Rassembler pour l’exportation + Collect in New Layer Rassembler dans un nouveau calque + Collect Links in a Separate Folder Rassembler les liens dans un dossier distinct + Color Couleur + Color (,) Couleur (,) + Color Adjustments Réglages de couleur + Color bars Barres de couleurs + Color blindness – Deuteranopia-type Daltonisme – type deutéranopie + Color blindness – Protanopia-type Daltonisme – type protanopie + Color Burn Densité couleur + + Color conversion: Conversion des couleurs : + Color Dodge Densité couleur - + Color Group Groupe de couleurs + Color Group: Groupe de couleurs : + Color Group: {name} Groupe de couleurs : {name} + Color Guide Guide des couleurs + Color Guide Options Options du guide des couleurs + Color Guide Options… Options du guide des couleurs… + Color images Images couleur + Color Library Bibliothèque de couleurs + Color Library… Bibliothèque de couleurs… + Color Management Gestion des couleurs + Color Mixer (Shift-click the chip) Mélangeur de couleurs (Maj+clic sur la vignette) + Color Mode Mode colorimétrique + Color Mode: Mode colorimétrique : + Color Model: Modèle colorimétrique : + Color Models Modèles colorimétriques + Color Picker Sélecteur de couleurs + Color Picker… Sélecteur de couleurs… + Color Reduction: Réduction des couleurs : + Color Settings Paramètres de couleur + Color Settings… Paramètres de couleur… + Color Space Espace colorimétrique + Color Swatches Nuances de couleur + Color Table Table des couleurs + Color Table ({n} color) Table des couleurs ({n} couleur) + Color Table ({n} colors) Table des couleurs ({n} couleurs) + Color Theme Thème de couleurs + Color Themes Thèmes de couleurs + Color Type: Type de couleur : + Color: Couleur : + Colorize Coloriser + Colors Couleurs + Colors: Couleurs : + Column Graph Tool Outil Graphe en colonnes + Columns: Colonnes : + Column… Colonne… + Combine Slices Combiner les tranches + Coming soon — tracked in the parity plan Bientôt disponible — prévu dans le plan de parité + Command Click to Select Objects Behind Cmd+clic pour sélectionner les objets situés derrière + Command Dialog Boîte de dialogue des commandes + Community Communauté + Compatibility: Compatibilité : + Compatible Gradient and Gradient Mesh Printing Impression compatible des dégradés et des filets de dégradé + Complement Complémentaire + Complexity: Complexité : + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Les couleurs composites sont converties vers ce profil ; les séparations l’utilisent lorsqu’il s’agit d’un profil CMJN. + Compound Path Tracé transparent + Compress (RLE) Compresser (RLE) + Compress text and line art Compresser le texte et les dessins au trait + Compression Compression + Compression › Color › Above Ppi Compression › Couleur › Au-dessus de ppp + Compression: Compression : + Condition identifier: Identifiant de condition : + Conflict {n} of {count} Conflit {n} sur {count} + Connect Selected End Points Relier les extrémités sélectionnées + Consolidate All Windows Regrouper toutes les fenêtres + Constrain Angle Contraindre l’angle + Constrain Width and Height Proportions Conserver les proportions de largeur et de hauteur + Construction Guides Repères de construction + Contextual Alternates Variantes contextuelles + Contextual Task Bar Barre des tâches contextuelle + Pin Bar Position Épingler la position de la barre + Reset Bar Position Réinitialiser la position de la barre + Contrast Contraste + Control Contrôle + Convert Convertir + Convert All Spot Colors to Process Convertir tous les tons directs en couleurs quadrichromiques + Convert All Strokes to Outlines Vectoriser tous les contours + Convert All Text to Outlines Vectoriser tout le texte + Convert Anchor Point Convertir le point d’ancrage + Convert Anchor Points Convertir les points d’ancrage + Convert Color Convertir la couleur + Convert Document Color Mode Convertir le mode colorimétrique du document + Convert Process to Global Convertir les couleurs quadrichromiques en globales + Convert Selected Anchor Points to Corner Convertir les points d’ancrage sélectionnés en sommets + Convert Selected Anchor Points to Smooth Convertir les points d’ancrage sélectionnés en points d’inflexion + Convert text to outlines Vectoriser le texte + Convert To Area Type Convertir en texte captif + Convert to Artboards Convertir en plans de travail + Convert to CMYK Convertir en CMJN + Convert to Grayscale Convertir en niveaux de gris + Convert To Outlines Vectoriser + Convert To Point Type Convertir en texte de point + Convert to RGB Convertir en RVB + Convert to Shape Convertir en forme + Convert to sRGB Convertir en sRGB + Convert: Convertir : + Copied the CSS of {rules} object(s) CSS copié pour {rules} objet(s) + Copies: Copies : + Copy Copier + Copy (make a transformed copy) Copier (créer une copie transformée) + Copy All Objects Copier tous les objets + Copy All Styles Copier tous les styles + Copy Color Value (Hex) Copier la valeur de couleur (hex) + 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 Width Point Copier le point de largeur + Copyright Copyright + Copyright Info URL: URL des informations de copyright : + Copyright Notice: Mention de copyright : + Copyright Status: État du copyright : + Copyright Symbol Symbole de copyright + Corner Radius Rayon de l’angle + Corner Radius: Rayon de l’angle : + Corner: Angle : + Corners Angles + Chamfer Chanfrein + Corners… Angles… + Cut Path Couper le tracé + Descender Jambage inférieur + Flip Retourner + Inverted Round Arrondi inversé + Radius: Rayon : + Round Arrondi + Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} + Couldn't paste from the clipboard: {e} Impossible de coller depuis le presse-papiers : {e} + Couldn't save recovery data: {e} Impossible d’enregistrer les données de récupération : {e} + Couldn't {} {name}: {e} Impossible de {} {name} : {e} + Create Créer + Create Clipping Mask Créer un masque d’écrêtage + Create Document Créer un document + Create from Guides Créer à partir des repères + Create from Selection Créer à partir de la sélection + Create From: Créer à partir de : + Create Gradient Mesh… Créer un filet de dégradé… + Create Graph Créer un graphe + Create New Layer Créer un calque + Create New Sublayer Créer un sous-calque + Create New Swatch… Créer une nuance… + Create New {kind} Style Créer un style de {kind} + Create Object Mosaic… Créer une mosaïque d’objets… + Create Outlines Vectoriser + Create Path Créer un tracé + Create PDF layers from layers and sublayers Créer des calques PDF à partir des calques et sous-calques + Create PDF layers from top-level layers Créer des calques PDF à partir des calques de premier niveau + Create PDF-Compatible File Créer un fichier compatible PDF + Create Report Créer un rapport + Create Slice Créer une tranche + Create Sub-folders Créer des sous-dossiers + Create Text Créer du texte + Create Trim Marks Créer des traits de coupe + Created Création + Created: Création : + Crop Recadrer + Crop Image Recadrer l’image + Mask Masque + Crop Marks Traits de coupe + Crop To Recadrer selon + Crystallize Tool Outil Cristallisation + CSS Properties Propriétés CSS + Ctrl Ctrl + Currency & Symbols Devises et symboles + Current Brush Forme active + Current Colors ({total}) Couleurs actuelles ({total}) + Current Symbol Symbole actif + Curvature Courbure + Curvature Tool Outil Courbure + Curves… Courbes… + Custom Personnalisé + Customize… Personnaliser… + Cut Couper + Cut Path at Selected Anchor Points Couper le tracé aux points d’ancrage sélectionnés + Cyan Cyan + Dark Sombre + Darken Obscurcir + dash tiret + Dash Pattern Motif de tirets + Dashed Pointillés + Dashed Line Ligne pointillée + Data Recovery Récupération des données + Data… Données… + Dates Dates + Decimal-Justified Tab Tabulation décimale + Decimal: Décimale : + Decorative Décorative + Default Par défaut + Default Actions Scripts par défaut + Default Figure Chiffres par défaut + Default Fill and Stroke Fond et contour par défaut + Default Fill and Stroke (D) Fond et contour par défaut (D) + Default Language Langue par défaut + Default Printer Imprimante par défaut + Default Swatches Nuancier par défaut + Define Grid… Définir la grille… + Define Perspective Grid Définir la grille de perspective + Define Perspective Grid… Définir la grille de perspective… + Delete Supprimer + Delete All Tout supprimer + Delete Anchor Point Supprimer le point d’ancrage + Delete Anchor Point Tool Outil Suppression de point d’ancrage + Delete Anchor Points Supprimer les points d’ancrage + Delete Artboard Supprimer le plan de travail + Delete Artboards Supprimer les plans de travail + Delete Brush Supprimer la forme + Delete Character Style Supprimer le style de caractère + Delete Empty Artboards Supprimer les plans de travail vides + Delete Freeform Point Supprimer le point libre + Delete Graphic Style Supprimer le style graphique + Delete Graphic Style (Alt-click: without asking) Supprimer le style graphique (Alt+clic : sans confirmation) + Delete Layer Supprimer le calque + Delete Mesh Point Supprimer le point de filet + Delete Paragraph Style Supprimer le style de paragraphe + Delete Pattern Supprimer le motif + Delete PDF Preset Supprimer le paramètre prédéfini PDF + Delete Perspective Grid Preset Supprimer le paramètre prédéfini de grille de perspective + Delete Point Supprimer le point + Delete Preset Supprimer le paramètre prédéfini + Delete Print Preset Supprimer le paramètre prédéfini d’impression + Delete Profile Supprimer le profil + Delete Save for Web Preset Supprimer le paramètre prédéfini Enregistrer pour le Web + Delete Selected Item Supprimer l’élément sélectionné + Delete Selection Supprimer la sélection + Delete Slices Supprimer les tranches + Delete Spine Point Supprimer le point de la dorsale + Delete Stop Supprimer le point de couleur + Delete Swatch Supprimer la nuance + Delete Symbol Supprimer le symbole + Delete Tab Supprimer la tabulation + Delete the graphic style “{name}”? Supprimer le style graphique « {name} » ? + Delete Theme Supprimer le thème + Delete theme Supprimer le thème + Delete these {count} swatches and groups? Supprimer ces {count} nuances et groupes ? + Delete these {n} graphic styles? Supprimer ces {n} styles graphiques ? + Delete this saved preset Supprimer ce paramètre prédéfini enregistré + Delete this {n} graphic style? Supprimer {n} style graphique ? + Delete Transparency Flattener Preset Supprimer le paramètre prédéfini d’aplatissement des transparences + Delete Width Point Supprimer le point de largeur + Delete Workspace Supprimer l’espace de travail + Delete {kind} Style Supprimer le style de {kind} + Delete “{name}”? Supprimer « {name} » ? + Depth: Profondeur : + Description Description + Description: Description : + Deselect Désélectionner + Design… Conception… + Destination: Destination : + Detail: Détail : + Devices Périphériques + Dialog Boîte de dialogue + Diamond Losange + Difference Différence + Differences from {preset}: Différences par rapport à {preset} : + Dim Copies to: Estomper les copies à : + Dim Images to: Estomper les images à : + Dimensions Dimensions + Direct Selection Tool Outil Sélection directe + Direction Handles Poignées de direction + Disable Auto Add/Delete Désactiver l’ajout/suppression automatique + Disable Opacity Mask Désactiver le masque d’opacité + Discard Ignorer + Discard Empty Type Supprimer le texte vide + Discard Recovered Documents Ignorer les documents récupérés + Discard White Overprint in Output Ignorer la surimpression du blanc en sortie + Discarded {} recovered document(s) {} document(s) récupéré(s) ignoré(s) + Discord Discord + Discretionary Hyphen Césure conditionnelle + Discretionary Ligatures Ligatures conditionnelles + Display All Blacks Accurately Afficher tous les noirs avec précision + Display All Blacks as Rich Black Afficher tous les noirs en noir quadrichromique + Display Bitmaps as Anti-aliased Images in Pixel Preview Afficher les bitmaps lissés dans l’aperçu des pixels + Display Options Options d’affichage + Display Print Size at 100% Zoom Afficher la taille d’impression avec un zoom de 100 % + Distort & Transform Distorsion et transformation + Distort Appearance Déformer l’aspect + Distort Linear Gradients Déformer les dégradés linéaires + Distort Pattern Fills Déformer les fonds de motif + Distortion Distorsion + Distribute Répartir + Distribute Objects: Répartition des objets : + Distribute Spacing Répartir l’espacement + Distribute Spacing: Répartition de l’espacement : + Dither: Simulation : + Divide Diviser + Divide Horizontally Into Diviser horizontalement en + Divide Objects Below Diviser les objets sous-jacents + Divide Slices Diviser les tranches + Divide Slices… Diviser les tranches… + Divide Vertically Into Diviser verticalement en + Do Not Move Ne pas déplacer + Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ? + Document Document + Document Color Mode Mode colorimétrique du document + Document Info Informations sur le document + Document Ink Options Options d’encre du document + Document JSON JSON du document + Document open password Mot de passe d’ouverture du document + Document profile Profil du document + Document Raster Effects Settings Paramètres de pixellisation du document + Document Raster Effects Settings Dialog Boîte de dialogue Paramètres de pixellisation du document + Document Raster Effects Settings… Paramètres de pixellisation du document… + Document Setup Format de document + Document Setup… Format de document… + Document Swatches Nuances du document + Document Title: Titre du document : + Documents you create appear here. Les documents que vous créez apparaissent ici. + Don't Save Ne pas enregistrer + Don't Show Center Masquer le centre + Done Terminé + Dots Points + Double Click To Isolate Double-cliquer pour isoler + Double Left Quotation Marks Guillemets doubles ouvrants + Double Quotes: Guillemets doubles : + Double Right Quotation Marks Guillemets doubles fermants + Double-click Double-clic + Double-click to edit the characters' paint Double-cliquez pour modifier la peinture des caractères + Double-click to insert Double-cliquez pour insérer + Double-click to rename Double-cliquez pour renommer + Double-click to target the contents Double-cliquez pour cibler le contenu + Download Télécharger + Download: Téléchargement : + Downloaded {what} {what} téléchargé + Downloaded {} file(s) {} fichier(s) téléchargé(s) + Drag Faire glisser + Drag a handle Faites glisser une poignée + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Faites glisser l’illustration ici, ou sélectionnez-la et cliquez sur +. Maintenez Alt pour une seule ressource à partir de plusieurs objets. + Drag onto art to apply this appearance Faites glisser sur l’illustration pour appliquer cet aspect + Drag the art to move it on the paper Faites glisser l’illustration pour la déplacer sur le papier + Draw Dessiner + Draw Behind Dessin arrière + Draw in Perspective Dessiner en perspective + Draw Inside Dessin intérieur + Draw Normal Dessin normal + Draw: Dessiner : + Drawing Mode Mode de dessin + Drop Shadow Ombre portée + Drop Shadow… Ombre portée… + Duotone Gradients Dégradés bichromes + Duplicate Dupliquer + Duplicate and Offset 10 pt Dupliquer et décaler de 10 pt + Duplicate Artboard Dupliquer le plan de travail + Copy Artboard Copier le plan de travail + Cut Artboard Couper le plan de travail + Duplicate Artboards Dupliquer les plans de travail + Duplicate Brush Dupliquer la forme + Duplicate Character Style Dupliquer le style de caractère + Duplicate Effect Dupliquer l’effet + Duplicate Graphic Style Dupliquer le style graphique + Duplicate Item Dupliquer l’élément + Duplicate Layer Dupliquer le calque + Duplicate Paragraph Style Dupliquer le style de paragraphe + Duplicate Selected Item Dupliquer l’élément sélectionné + Duplicate Selection Dupliquer la sélection + Duplicate Slice Dupliquer la tranche + Duplicate Swatch Dupliquer la nuance + Duplicate Symbol Dupliquer le symbole + Duplicate {kind} Style Dupliquer le style de {kind} + Dutch Néerlandais + DXF Import Options Options d’importation DXF + DXF Info Informations DXF + DXF Options Options DXF + DXF Options Dialog Boîte de dialogue Options DXF + e.g. 1-3, 5 p. ex. 1-3, 5 + Each page prints as one image, at the document's raster effects resolution. Chaque page s’imprime comme une seule image, à la résolution de pixellisation du document. + Earth Tones Tons terre + East Asian Type Texte d’Asie orientale + Edit Édition + Edit Artboards Modifier les plans de travail + Edit Clipping Path Modifier le tracé d’écrêtage + Edit Color Group Modifier le groupe de couleurs + Edit Colors Modifier les couleurs + Edit Contents Modifier le contenu + Edit Custom Dictionary… Modifier le dictionnaire personnalisé… + Edit Envelope Modifier l’enveloppe + Edit Freeform Point Modifier le point libre + Edit Gradient Modifier le dégradé + Edit Opacity Mask Modifier le masque d’opacité + Edit or Apply Color Group Modifier ou appliquer le groupe de couleurs + Edit or Apply Color Group… Modifier ou appliquer le groupe de couleurs… + Edit or Apply Colors Modifier ou appliquer les couleurs + Edit Original Modifier l’original + Edit Pattern Modifier le motif + Edit PDF Preset Modifier le paramètre prédéfini PDF + Edit Print Preset Modifier le paramètre prédéfini d’impression + Edit selected paths within: Modifier les tracés sélectionnés à moins de : + Edit Selection Modifier la sélection + Edit Selection… Modifier la sélection… + Edit Symbol Modifier le symbole + Edit Text Modifier le texte + Edit the applied effect, or add another one? Modifier l’effet appliqué ou en ajouter un autre ? + Edit the selected point's colour in the Color panel Modifier la couleur du point sélectionné dans le panneau Couleur + Edit the stop Modifier le point de couleur + Edit Theme Modifier le thème + Edit theme Modifier le thème + Edit Toolbar Modifier la barre d’outils + Edit Views Modifier les vues + Edit Views… Modifier les vues… + Edit… Modifier… + Effect Effet + Effect Options Options d’effet + Effective PPI PPP effectifs + Effects Effets + Effect… Effet… + Ellipse Ellipse + Ellipse Tool Outil Ellipse + Ellipse… Ellipse… + Ellipsis Points de suspension + Em Box Bottom/Left Cadratin bas/gauche + Em Box Center Centre du cadratin + Em Box Top/Right Cadratin haut/droite + Em Dash Tiret cadratin + Em Space Espace cadratin + Embed Incorporer + Embed Fonts (for other applications) Incorporer les polices (pour d’autres applications) + Embed Fonts (Glyphs Used) Incorporer les polices (glyphes utilisés) + Embed ICC Profile Incorporer le profil ICC + Embed ICC Profiles Incorporer les profils ICC + Embed Image Incorporer l’image + Embed Image(s) Incorporer les images + Embed page thumbnails Incorporer les vignettes de page + Embedded Incorporé + Embedded Image Image incorporée + Embedded {n} image(s) {n} image(s) incorporée(s) + Embedded {n} image(s); {missing} missing: relink them first {n} image(s) incorporée(s) ; {missing} manquante(s) : rétablissez d’abord leurs liens + Emulsion: Émulsion : + En Dash Tiret demi-cadratin + En Space Espace demi-cadratin + Enable Background Export Activer l’exportation en arrière-plan + Enable Background Save Activer l’enregistrement en arrière-plan + Enable Contextual Task Bar Activer la barre des tâches contextuelle + Enable copying of text, images and other content Autoriser la copie de texte, d’images et d’autres contenus + Enable in-menu font previews Activer l’aperçu des polices dans les menus + Enable Missing Glyph Protection Activer la protection des glyphes manquants + Enable Opacity Mask Activer le masque d’opacité + Enable plaintext metadata Activer les métadonnées en texte brut + Enable Rubber Band for Activer l’élastique pour + Enable text access for screen readers Autoriser l’accès au texte pour les lecteurs d’écran + Enable Touch Gestures Activer les gestes tactiles + Enable Touch Workspace Activer l’espace de travail tactile + Encoding Codage + Encoding: Codage : + Encryption: {name} (set by Compatibility) Chiffrement : {name} (défini par la compatibilité) + End arrowhead Pointe de flèche de fin + English English + English: UK Anglais : Royaume-Uni + English: USA Anglais : États-Unis + Enter Entrée + Enter Isolation Mode Passer en mode Isolation + Entire Font Police entière + Envelope Distort Distorsion de l’enveloppe + Envelope Info Informations sur l’enveloppe + Envelope Mesh Filet d’enveloppe + Envelope Options Options d’enveloppe + Envelope Options… Options d’enveloppe… + EPS Options Options EPS + EPS Options Dialog Boîte de dialogue Options EPS + Eraser Gomme + Eraser Tool Outil Gomme + Error Erreur + Esc Échap + Every (minutes) Toutes les (minutes) + Every option is at its default. Toutes les options ont leur valeur par défaut. + Every option matches {base}. Toutes les options correspondent à {base}. + Every-line Composer Composeur multiligne + Exact dash lengths Longueurs de tirets exactes + Exceptions (comma separated) Exceptions (séparées par des virgules) + Exclude Exclure + Exclusion Exclusion + Exit Isolation Mode Quitter le mode Isolation + Expand Décomposer + Expand Appearance Décomposer l’aspect + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Décomposer s’applique aux formes composées (Alt+clic sur un mode de forme) — prévu dans la feuille de route + Expand Brush Strokes Décomposer les contours de forme + Expand Compound Shape Décomposer la forme composée + Expand Gradient Mesh Décomposer le filet de dégradé + Expand Gradient To Décomposer le dégradé en + Expand Layers Panel Rows Développer les lignes du panneau Calques + Expand Options Options de décomposition + Expand Panels Développer les panneaux + Expand Repeat Décomposer la répétition + Expand Shape Décomposer la forme + Expand: Décomposer : + Expand… Décomposer… + Export Exporter + Export All… Tout exporter… + Export and Clipboard Transparency Flattener Settings Paramètres d’aplatissement des transparences pour l’exportation et le presse-papiers + Export Artboard Exporter le plan de travail + Export As Exporter sous + Export As PNG… Exporter au format PNG… + Export As SVG… Exporter au format SVG… + Export Asset Exporter la ressource + Export Assets Exporter les ressources + Export As… Exporter sous… + Export CSS Exporter le CSS + Export CSS… Exporter le CSS… + Export Document Exporter le document + Export DXF Exporter en DXF + Export EPS Exporter en EPS + Export for Screens Exporter pour les écrans + Export for Screens Settings Paramètres d’Exporter pour les écrans + Export for Screens… Exporter pour les écrans… + Export Keyboard Shortcuts… Exporter les raccourcis clavier… + Export Option Option d’exportation + Export PDF Exporter en PDF + Export PDF Presets Exporter les paramètres prédéfinis PDF + Export Perspective Grid Presets Exporter les paramètres prédéfinis de grille de perspective + Export Print Presets Exporter les paramètres prédéfinis d’impression + Export Selected Art Only Exporter uniquement l’illustration sélectionnée + Export Selected CSS… Exporter le CSS sélectionné… + Export Selection… Exporter la sélection… + Export Settings Paramètres d’exportation + Export the selected assets (else all of them) Exporter les ressources sélectionnées (sinon toutes) + Export to: Exporter vers : + Export Transparency Flattener Presets Exporter les paramètres prédéfinis d’aplatissement des transparences + Export {count} Selected Exporter la sélection ({count}) + Export: Exporter : + Exported {count} file(s) to {folder} {count} fichier(s) exporté(s) vers {folder} + Exported {path} {path} exporté + Exported {what} {what} exporté + Exported {what} with {} note(s): {first} {what} exporté avec {} remarque(s) : {first} + Exporting {} Exportation de {} + Export… Exporter… + Extrude & Bevel… Extrusion et biseau… + Eyedropper Pipette + Eyedropper Applies: La pipette applique : + Eyedropper Options Options de la pipette + Eyedropper Picks Up: La pipette prélève : + Eyedropper Tool Outil Pipette + Eyedropper: click the art to sample the stop's colour Pipette : cliquez sur l’illustration pour prélever la couleur du point de couleur + Favorites Favoris + Feather… Contour progressif… + Feet Pieds + Feet & Inches Pieds et pouces + Fewer Elements Moins d’éléments + Fidelity: Fidélité : + Figure Space Espace numérique + Figure: Chiffres : + File Fichier + File Dimensions Dimensions du fichier + File Format: Format de fichier : + File Formats Formats de fichier + File Handling Gestion des fichiers + File Info Informations sur le fichier + File Info Dialog Boîte de dialogue Informations sur le fichier + File Info… Informations sur le fichier… + File Name: Nom du fichier : + File › Place… brings in an image, linked or embedded. Fichier › Importer… insère une image, liée ou incorporée. + Files Fichiers + Files of a PDF standard can't be password-protected. Les fichiers conformes à une norme PDF ne peuvent pas être protégés par mot de passe. + Files of this standard tag their colours with ICC profiles. Les fichiers conformes à cette norme balisent leurs couleurs avec des profils ICC. + Fill Fond + Fill & Stroke Fond et contour + Fill (X), double-click for the Color Picker Fond (X), double-cliquez pour ouvrir le Sélecteur de couleurs + Fill and Stroke Fond et contour + Fill new strokes Remplir les nouveaux contours + Fill Color Couleur de fond + Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution + Fill Rule Règle de remplissage + Fill with Placeholder Text Remplir avec un texte de substitution + Fill: Fond : + Fill: click for swatches, Shift-click for the color mixer Fond : cliquez pour le nuancier, Maj+clic pour le mélangeur de couleurs + Find Rechercher + Find and Replace… Rechercher et remplacer… + Find Font Rechercher une police + Find Font… Rechercher une police… + Find Next Rechercher le suivant + Find Objects Rechercher des objets + First Page Première page + First-line Left Indent Retrait gauche de première ligne + Fisheye… Fish-eye… + Fish… Poisson… + Fit All Tout ajuster + Fit All in Window Tout ajuster à la fenêtre + Fit Artboard in Window Ajuster le plan de travail à la fenêtre + Fit dashes to corners and ends Ajuster les tirets aux angles et aux extrémités + Fit Headline Ajuster le titre + Decrease Font Size Réduire la taille de police + Increase Font Size Augmenter la taille de police + Spacing Espacement + Step Type Texte par incréments + Fit in View Ajuster à la vue + Fit in Window Ajuster à la fenêtre + Fit on Screen Ajuster à l’écran + Fit to Artwork Bounds Ajuster aux limites de l’illustration + Fit to Selected Art Ajuster à l’illustration sélectionnée + Flag… Drapeau… + Flare Halo + Flare Tool Outil Halo + Flare Tool Options Options de l’outil Halo + Flat Image Image aplatie + Flatness: Planéité : + Flatten Artwork Aplatir l’illustration + Flatten Transparency Aplatir la transparence + Flatten Transparency… Aplatir la transparence… + Flattener preset: Paramètre prédéfini d’aplatissement : + Flattener presets Paramètres prédéfinis d’aplatissement + Flattener Preview Aperçu de l’aplatissement + Flip Across Retourner en travers + Flip Along Retourner dans la longueur + Flip Along Horizontal Axis Retourner selon l’axe horizontal + Flip Along Vertical Axis Retourner selon l’axe vertical + Flip Horizontal Retourner horizontalement + Flip Row Order Inverser l’ordre des lignes + Flip Vertical Retourner verticalement + Float in Window Flottant dans une fenêtre + Focal Fill Fond actif + Focal Stroke Contour actif + Folder Dossier + Folder Name: Nom du dossier : + Font Police + Font Family Famille de polices + Font Family & Style Famille et style de police + Font Family, Style & Size Famille, style et taille de police + Font List Liste des polices + Font Preview Size Taille de l’aperçu des polices + Font Size Taille de police + Font: Police : + Fonts Polices + Fonts in Document Polices du document + Fonts in Document: {count} Polices du document : {count} + Fonts whose licence doesn't allow embedding are not copied. Les polices dont la licence n’autorise pas l’incorporation ne sont pas copiées. + Forced Line Break Saut de ligne forcé + Format Format + Format Options Options de format + Format Settings Paramètres de format + Format: Format : + Formats: Formats : + Fractional Widths Largeurs fractionnaires + Fractions Fractions + Free Libre + Free Distort Distorsion libre + Free Distort… Distorsion libre… + Free Transform Tool Outil Transformation manuelle + Freeform Gradient Dégradé de forme libre + French Français + Frequencies and angles are the output device's to apply. Les linéatures et les angles sont appliqués par le périphérique de sortie. + Frequency Linéature + Full Document Document entier + Full Screen Mode Mode Plein écran + Full Screen Mode with Menu Bar Mode Plein écran avec barre de menus + Gamma Gamma + Gamut Check Couleurs non imprimables + Gamut Warning Alerte de gamme + gap espace + Gap Options… Options d’espacement… + Gaussian Blur… Flou gaussien… + General Général + General › Placement › X Général › Placement › X + General › Thumbnails Général › Vignettes + Generate CSS Générer le CSS + Generate CSS: the whole document's CSS while nothing is selected Générer le CSS : le CSS de tout le document lorsque rien n’est sélectionné + German Allemand + Get Preferences Obtenir les préférences + Get Text Range Obtenir une plage de texte + Global Globale + Global Brush Dimensions Dimensions globales des formes + Global Process Color Couleur quadrichromique globale + Global Process Color, CMYK Couleur quadrichromique globale, CMJN + glyph {id} glyphe {id} + Glyphs Glyphes + Go to Artboard Atteindre le plan de travail + Go To Link Atteindre le lien + GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. + GPU Performance Performances GPU + Gradient Dégradé + Gradient (.) Dégradé (.) + Gradient across stroke Dégradé sur la largeur du contour + Gradient along stroke Dégradé le long du contour + Gradient and Mesh Resolution: Résolution des dégradés et des filets : + Gradient Fill: click to apply, drag onto art Fond dégradé : cliquez pour appliquer, faites glisser sur l’illustration + Gradient Finishes Finitions de dégradé + Gradient Mesh Filet de dégradé + Gradient swatches Nuances de dégradé + Gradient Tool Outil Dégradé + Gradient Vector Vecteur de dégradé + Gradient within stroke Dégradé à l’intérieur du contour + Gradients Dégradés + Graph Graphe + Graph Data Données du graphe + Graph Type Type de graphe + Graphic Style Style graphique + Graphic Style Libraries Bibliothèques de styles graphiques + Graphic Style Libraries Menu Menu Bibliothèques de styles graphiques + Graphic Style Library Bibliothèque de styles graphiques + Graphic Style Options Options de style graphique + Graphic Style Options… Options de style graphique… + Graphic Styles Styles graphiques + Graphic Styles Options Options des styles graphiques + Graphics Graphismes + Graphics Processor Processeur graphique + Graphics: {adapter} Carte graphique : {adapter} + Gravity Gravité + Grays Gris + Grays and Neutrals Gris et neutres + Grayscale Niveaux de gris + Grayscale images Images en niveaux de gris + Greek & Cyrillic Grec et cyrillique + Green Vert + Grid Grille + Grid Color & Opacity Couleur et opacité de la grille + Grid Colors: Couleurs de la grille : + Grid Size: Taille de la grille : + Gridline every Pas de la grille + Gridline every: Pas de la grille : + Grids In Back Grilles à l’arrière-plan + Group Grouper + Group Selection Tool Outil Sélection d’éléments du groupe + Guides Repères + Guides & Grid Repères et grille + H Spacing: Espacement H : + H: H : + Hair Space Espace ultrafine + Hand Tool Outil Main + Hand-Drawn Dessiné à la main + Handles Poignées + Hard Light Lumière crue + Harmony Rules: Règles d’harmonie : + Harmony Sets Ensembles d’harmonies + Height Hauteur + Height: Hauteur : + Help Aide + Help › Search Commands… Aide › Rechercher des commandes… + Hex by Column Hexagone par colonne + Hex by Row Hexagone par ligne + Hide Masquer + Hide Artboards Masquer les plans de travail + Hide Bounding Box Masquer le cadre de sélection + Hide Code Masquer le code + Hide Bar Masquer la barre + More Options Plus d’options + Show Properties Panel Afficher le panneau Propriétés + Hide Corner Widget Masquer le widget d’angle + Hide Corner Widget for angles greater than Masquer le widget d’angle pour les angles supérieurs à + Hide Edges Masquer les bords + Hide Gradient Annotator Masquer l’annotateur de dégradé + Hide Grid Masquer la grille + Hide Guides Masquer les repères + Hide Hidden Characters Masquer les caractères masqués + Hide Note Masquer la note + Hide Options Masquer les options + Hide Others Masquer les autres + Hide Print Tiling Masquer la mosaïque d’impression + Hide Rulers Masquer les règles + Hide Slices Masquer les tranches + Hide Text Threads Masquer les liens de texte + Hide Thumbnail Masquer la vignette + Hide Thumbnails Masquer les vignettes + Hide Transparency Grid Masquer la grille de transparence + High Élevée + High (300 ppi) Élevée (300 ppp) + High Performance (discrete) Hautes performances (dédiée) + Highlight Alternate Glyphs Surligner les glyphes de substitution + Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol + Highlight Rows Surligner les lignes + Highlight Substituted Fonts Surligner les polices remplacées + Highlight Substituted Glyphs Surligner les glyphes remplacés + History Historique + History Options… Options d’historique… + History States États de l’historique + Hollow Évidé + Home Accueil + Horizon Height: Hauteur de l’horizon : + Horizontal Horizontal + Horizontal % % horizontal + Horizontal Align Center Alignement centré horizontal + Horizontal Align Left Alignement à gauche horizontal + Horizontal Align Right Alignement à droite horizontal + Horizontal Distribute Center Répartition horizontale centrée + Horizontal Distribute Left Répartition horizontale à gauche + Horizontal Distribute Right Répartition horizontale à droite + Horizontal Distribute Space Répartition horizontale de l’espacement + Horizontal Grid: Grille horizontale : + Horizontal Plane Plan horizontal + Horizontal Scale % % de mise à l’échelle horizontale + Horizontal: Horizontal : + How colours outside the press's gamut are separated. Comment séparer les couleurs hors de la gamme de la presse. + Hue Teinte + Hue/Saturation… Teinte/saturation… + Hyphenate Césure + Hyphenation Césure + Hyphenation… Césure… + ICC Profile Profil ICC + Identify Objects By Identifier les objets par + Ignore Ignorer + Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page) + Ignore White Ignorer le blanc + Image Image + Image Info Informations sur l’image + Image Map: Image cliquable : + Image Size Taille de l’image + Image Trace Vectorisation de l’image + Image Tracing Objet de vectorisation de l’image + Tracing Presets Paramètres prédéfinis de vectorisation + Image Trace Presets Paramètres prédéfinis de vectorisation de l’image + Image: Image : + Images: Images : + Import Keyboard Shortcuts… Importer des raccourcis clavier… + Import PDF Importer un PDF + Import PDF Presets Importer des paramètres prédéfinis PDF + Import Perspective Grid Presets Importer des paramètres prédéfinis de grille de perspective + Import Print Presets Importer des paramètres prédéfinis d’impression + Import Transparency Flattener Presets Importer des paramètres prédéfinis d’aplatissement des transparences + Imported {what}: {} {what} importé : {} + Import… Importer… + In Tangent Handles Poignées de tangente entrantes + In the selected objects Dans les objets sélectionnés + Inches Pouces + Include Blacks with CMY: Inclure les noirs avec CMJ : + Include Bleed Inclure le fond perdu + Include Cmy Blacks Inclure les noirs CMJ + Include CMYK PostScript in RGB Files Inclure le PostScript CMJN dans les fichiers RVB + Include Document Thumbnails Inclure les vignettes du document + Include Hidden Layers Inclure les calques masqués + Include Linked Files Inclure les fichiers liés + Include Metadata Inclure les métadonnées + Include non-printing layers Inclure les calques non imprimables + Include SVG Code Inclure le code SVG + Include Swatches for Tints Inclure les nuances des teintes + Inflate… Gonfler… + Info Infos + Ink Encre + Inner Glow… Lueur interne… + Input Black Noir en entrée + Input White Blanc en entrée + Insert Break Character Insérer un caractère de saut + Insert Character Insérer un caractère + Insert Special Character Insérer un caractère spécial + Insert Whitespace Character Insérer une espace + Inspect Document Inspecter le document + Inspect Object Inspecter l’objet + Install Plug-in Installer un module externe + Install Plug-in… Installer un module externe… + Installed plug-in {} Module externe {} installé + Intensity: Intensité : + Intent: Mode de rendu : + Interlaced Entrelacé + Internal CSS CSS interne + Internet Standard RGB (sRGB) RVB standard Internet (sRGB) + Intersect Intersection + Inverse Inverser la sélection + Invert Inverser + Invert Colors Inverser les couleurs + Invert Mask Inverser le masque + Isolate Isoler + Isolate Blending Isoler le mode de fusion + Isolate Group Isoler le groupe + Italian Italien + Japanese Japonais + Join Joindre + Join Our Discord Rejoignez notre Discord + Join our Discord Rejoignez notre Discord + Join the ArtCraft community on Discord (discord.gg/artcraft) Rejoignez la communauté ArtCraft sur Discord (discord.gg/artcraft) + Join the lines of each paragraph Joindre les lignes de chaque paragraphe + Join Tool Outil Joindre + Justification… Justification… + Justify all lines Justifier toutes les lignes + Justify with last line aligned center Justifier, dernière ligne centrée + Justify with last line aligned left Justifier, dernière ligne alignée à gauche + Justify with last line aligned right Justifier, dernière ligne alignée à droite + Keep a link to an image file instead of only embedding it Conserver un lien vers le fichier image au lieu de seulement l’incorporer + Keep both sides the same width Garder la même largeur des deux côtés + Keep Formatting Conserver la mise en forme + Keep Original Conserver l’original + Keep Plain Text Conserver le texte brut + Keep the pasted color as a new swatch with a numbered name. Conserver la couleur collée comme nouvelle nuance avec un nom numéroté. + Keep this row's colors (exclude) Conserver les couleurs de cette ligne (exclure) + Keep: Conserver : + Kerning (0 = Auto) Crénage (0 = auto) + Keyboard Increment Incrément clavier + Keyboard Shortcut Conflicts Conflits de raccourcis clavier + Keyboard Shortcut Set Jeu de raccourcis clavier + Keyboard Shortcuts… Raccourcis clavier… + Keywords: Mots-clés : + Knife Cutter + Knockout Group Groupe de masquage + Lab Lab + Lab Spot Colors: Tons directs Lab : + Lab Values Valeurs Lab + Landscape Paysage + Interface Language Langue de l’interface + Language Langue + Language Options Options de langue + Language: Langue : + Large Grand + Large List View Grande vue en liste + Large Tabs Grands onglets + Large Thumbnail View Grandes vignettes + Large Thumbnails Grandes vignettes + Lasso Tool Outil Lasso + Last Effect: {name} Dernier effet : {name} + Last Effect: {} Dernier effet : {} + Last Effect… Dernier effet… + Last Page Dernière page + Latin Latin + Latin Extended Latin étendu + Latin-1 Supplement Supplément Latin-1 + Layer Calque + Layer Names Noms des calques + Layer Options Options de calque + Layer Options… Options de calque… + Layers Calques + Layers Panel Options Options du panneau Calques + Layout: Disposition : + Leader: Points de conduite : + Leading Interlignage + Left Gauche + Left Grid: Grille gauche : + Left in Front Gauche au premier plan + Left Indent Retrait gauche + Left Plane Plan gauche + Left-Justified Tab Tabulation alignée à gauche + Left-to-Right Paragraph Direction Sens du paragraphe de gauche à droite + Left: Gauche : + Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh hérité (gamma 1,8) + Legacy vector clipboard Presse-papiers vectoriel hérité + Legacy vector clipboard (no transparency) Presse-papiers vectoriel hérité (sans transparence) + Less Moins + Letter Lettre US + Levels… Niveaux… + Libraries Bibliothèques + Ligatures are off while the text is tracked. Les ligatures sont désactivées tant qu’une approche est appliquée au texte. + Light Clair + Light Blue Bleu clair + Lighten Éclaircir + Lightness Luminosité + Limit Color Guide to Library Limiter le guide des couleurs à la bibliothèque + Limit colors to swatch library Limiter les couleurs à la bibliothèque de nuances + Limit colors to swatch library: {name} Limiter les couleurs à la bibliothèque de nuances : {name} + Limit to Library: Limiter à la bibliothèque : + Limit: Limite : + Line Ligne + Line Art and Text Resolution: Résolution des dessins au trait et du texte : + Line Breaks Sauts de ligne + Line Color Couleur de ligne + Line Cut Coupe de ligne + Line Cut Tool Outil Coupe de ligne + Line Endings: Fins de ligne : + Line Graph Tool Outil Graphe en courbes + Line Segment Segment de ligne + Line Segment Tool Outil Segment de ligne + Line Segment Tool Options Options de l’outil Segment de ligne + Line-end Punctuation Half Width Ponctuation de fin de ligne en demi-chasse + Linear Gradient Dégradé linéaire + Lines Lignes + Lines: clicks add points joined by a line Lignes : chaque clic ajoute des points reliés par une ligne + Link Lier + Link harmony colors Lier les couleurs de l’harmonie + Link Info Informations sur le lien + Link Opacity Mask Lier le masque d’opacité + Link start and end arrowhead scales Lier les échelles des pointes de flèche de début et de fin + Link the mask Lier le masque + Linked Lié + Linked File Fichier lié + Linked File Not Found Fichier lié introuvable + Links Liens + Liquify Fluidité + List Graphic Styles Lister les styles graphiques + List Keyboard Shortcuts Lister les raccourcis clavier + List Patterns Lister les motifs + List Plug-ins Lister les modules externes + List Preferences Lister les préférences + List Slices Lister les tranches + List View Vue en liste + List Workspaces Lister les espaces de travail + Live Paint Peinture dynamique + Live Paint Bucket Pot de peinture dynamique + Live Paint Bucket (Stroke) Pot de peinture dynamique (contour) + Live Paint Info Informations sur la peinture dynamique + Live Paint Selection Tool Outil Sélection de peinture dynamique + Live Shape Properties Propriétés de la forme dynamique + Load Color Profile… Charger un profil colorimétrique… + Load Image into Clipboard Charger une image dans le presse-papiers + Load Metafile into Clipboard Charger un métafichier dans le presse-papiers + Load PDF into Clipboard Charger un PDF dans le presse-papiers + Load Place Cursor Charger le curseur d’importation + Load Profile… Charger un profil… + Load SVG into Clipboard Charger un SVG dans le presse-papiers + Load Text into Clipboard Charger du texte dans le presse-papiers + Local Libraries Bibliothèques locales + Locate Object Localiser l’objet + Location Emplacement + Location: Emplacement : + Lock Verrouiller + Lock (⌘2) Verrouiller (⌘2) + Lock Grid Verrouiller la grille + Lock Guides Verrouiller les repères + Lock Others Verrouiller les autres + Lock Slices Verrouiller les tranches + Lock Station Point Verrouiller le point d’observation + Lossless (lossy WebP isn't available yet) Sans perte (le WebP avec perte n’est pas encore disponible) + Lossy: Avec perte : + Low Faible + lowercase minuscules + Luminosity Luminosité + LZW Compression Compression LZW + Mac Mac + Magenta Magenta + Magic Wand Baguette magique + Magic Wand Options Options de la baguette magique + Magic Wand Settings Réglages de la baguette magique + Magic Wand Tool Outil Baguette magique + Maintain Envelope Shape Conserver la forme de l’enveloppe + Make Créer + Make a theme on Create and save it. Créez un thème dans l’onglet Créer et enregistrez-le. + Make all bleed settings the same Utiliser la même valeur pour tous les fonds perdus + Make and Expand Créer et décomposer + Make Clipping Mask Créer un masque d’écrêtage + Make Compound Shape Créer une forme transparente + Make Envelope Créer une enveloppe + Make Guides Créer des repères + Make Mask Créer un masque + Make Opacity Mask Créer un masque d’opacité + Make Pixel Perfect Aligner sur les pixels + Make the bleed values differ Utiliser des valeurs de fond perdu différentes + Make with Mesh… Créer avec un filet… + Make with Top Object Créer avec l’objet au premier plan + Make with Warp… Créer avec une déformation… + Make/Release Clipping Mask Créer/libérer un masque d’écrêtage + Manage Workspaces Gérer les espaces de travail + Manage Workspaces… Gérer les espaces de travail… + Manually Manuellement + Mark as trapped Marquer comme grossi-maigri + Mark Older Files as [Converted] When Opened Marquer les anciens fichiers comme [Converti] à l’ouverture + Marker… Marqueur… + Marks Marques + Marks and Bleed Marques et fond perdu + Marks and Bleeds Marques et fonds perdus + Match User Interface Brightness Adapter à la luminosité de l’interface + Materials… Matériaux… + Matte Détourage + Matte: Détourage : + Maximize Agrandir + Maximum Maximum + Maximum Editability Modification maximale + Measure Tool Outil Mesure + Measurement Labels Étiquettes de mesure + Media Support + Medium Moyen + Medium (150 ppi) Moyenne (150 ppp) + Medium Dark Moyennement foncé + Medium Light Moyennement clair + Medium Thumbnail View Affichage en vignettes moyennes + Medium Thumbnails Vignettes moyennes + Menu Dialog Boîte de dialogue du menu + Merge Fusionner + Merge colors into one row Fusionner les couleurs sur une ligne + Merge Graphic Styles Fusionner les styles graphiques + Merge Graphic Styles… Fusionner les styles graphiques… + Merge Layers Fusionner les calques + Merge Selected Fusionner la sélection + Merge Swatches Fusionner les nuances + Mesh Tool Outil Filet + Message: Message : + Metadata: Métadonnées : + Metallic Gradients Dégradés métalliques + Meters Mètres + Method: Méthode : + Millimeters Millimètres + Minify Minifier + Minimal Minimal + Minimize Réduire + Minus Back Soustraction arrière + Minus Front Soustraction avant + Mirror Symétrie + Mirror & Cut Symétrie et découpe + Mirror & Cut Tool Outil Symétrie et découpe + Missing Manquant + Missing: {path} Manquant : {path} + Miter Join Sommet en pointe + Miter Limit Limite de pointe + Miter Limit: Limite de pointe : + Mixed Appearances Aspects mixtes + Mixed Objects Objets mixtes + Mode: Mode : + Model Modèle + Modified Modifié + Modified since it was read: {path} Modifié depuis sa lecture : {path} + Modified: Modifié : + Modify Modifier + Mojikumi Mojikumi + Mojikumi Set Jeu de mojikumi + Monitor RGB RVB du moniteur + Mono Mono + Monochrome images Images monochromes + Monospaced À chasse fixe + More Plus + More Pathfinder options Plus d’options Pathfinder + More Settings Plus de réglages + Move Déplacer + Move All Objects Déplacer tous les objets + Move Anchors Déplacer les points d’ancrage + Move Appearance Déplacer l’aspect + Move Artboard Déplacer le plan de travail + Move Artboard Up/Down Monter/descendre le plan de travail + Move Direction Handle Déplacer la poignée de direction + Move Down Descendre + Move Effect Déplacer l’effet + Move Envelope Mesh Point Déplacer le point de filet de l’enveloppe + Move Graphic Style Déplacer le style graphique + Move Guide Déplacer le repère + Move in Perspective Déplacer en perspective + Move Locked and Hidden Artwork with Artboard Déplacer les illustrations verrouillées et masquées avec le plan de travail + Move Mesh Point Déplacer le point de filet + Move Plane Déplacer le plan + Move Plane to Match Object Déplacer le plan pour l’aligner sur l’objet + Move Rows Déplacer les lignes + Move Slices Déplacer les tranches + Move Spine Point Déplacer le point de la dorsale + Move Swatch Déplacer la nuance + Move Up Monter + Move… Déplacer… + Multiply Produit + My Themes Mes thèmes + N pt per N pt par + N slices …, evenly spaced N tranches …, espacées régulièrement + Name Nom + Name 40% Nom 40 % + Name* @ 66.67 % (RGB/Preview) Nom* @ 66,67 % (RVB/Aperçu) + Name* @ 66.67% (RGB/Preview) Nom* @ 66,67% (RVB/Aperçu) + Name: Nom : + Navigate Naviguer + Navigator Navigation + Needs the desktop app Nécessite l’application de bureau + Neutral Gradients Dégradés neutres + New Nouveau + New Art Appearance Aspect des nouvelles illustrations + New Art Brush from Selection Nouvelle forme artistique à partir de la sélection + New Art Has Basic Appearance Aspect de base pour les nouvelles illustrations + New Artboard Nouveau plan de travail + New Bristle Brush Nouvelle forme Soies + New Brush… Nouvelle forme… + New Calligraphic Brush Nouvelle forme calligraphique + New Character Style Nouveau style de caractère + New Color Group Nouveau groupe de couleurs + New Color Group… Nouveau groupe de couleurs… + New Document Presets Paramètres prédéfinis de nouveau document + New file Nouveau fichier + New from Template Nouveau d’après un modèle + New from Template… Nouveau d’après un modèle… + New Graphic Style Nouveau style graphique + New Graphic Style (Alt-click to name it) Nouveau style graphique (Alt+clic pour le nommer) + New Graphic Style… Nouveau style graphique… + New Layer Options… Options du nouveau calque… + New Layer… Nouveau calque… + New Opacity Masks Are Clipping Les nouveaux masques d’opacité sont écrêtés + New Opacity Masks Are Inverted Les nouveaux masques d’opacité sont inversés + New Paragraph Style Nouveau style de paragraphe + New Pattern Brush from Selection Nouvelle forme de motif à partir de la sélection + New PDF Preset Nouveau paramètre prédéfini PDF + New Print Preset Nouveau paramètre prédéfini d’impression + New Scatter Brush from Selection Nouvelle forme de diffusion à partir de la sélection + New Sublayer… Nouveau sous-calque… + New Swatch Nouvelle nuance + New Swatch… Nouvelle nuance… + New Symbol Nouveau symbole + New Symbol… Nouveau symbole… + New theme from the current color Nouveau thème à partir de la couleur actuelle + New View Nouvelle vue + New View… Nouvelle vue… + New Window Nouvelle fenêtre + New Workspace Nouvel espace de travail + New Workspace… Nouvel espace de travail… + New {kind} Style… Nouveau style de {kind}… + New… Nouveau… + Next Suivant + Next Library Bibliothèque suivante + Next Object Above Objet suivant au-dessus + Next Object Below Objet suivant au-dessous + Next Page Page suivante + Next page Page suivante + No actions. Press ● to record. Aucune action. Appuyez sur ● pour enregistrer. + No assets Aucune ressource + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Aucune ressource pour l’instant. Rassemblez des illustrations avec Objet › Rassembler pour l’exportation, ou faites-les glisser dans le panneau Exportation de ressources. + No Break Sans coupure + No brushes Aucune forme + No colors to recolor (preserved colors are left out). Aucune couleur à recolorier (les couleurs conservées sont exclues). + No CSS Aucun CSS + No document Aucun document + No Document Aucun document + No document open Aucun document ouvert + No document open. Aucun document ouvert. + No filter plug-ins installed Aucun module externe de filtre installé + No fonts are available. Aucune police disponible. + No gradient swatches Aucune nuance de dégradé + No graphic styles Aucun style graphique + No images Aucune image + No matches. Aucun résultat. + No matching fonts Aucune police correspondante + No options Aucune option + No printer settings to open here Aucun réglage d’imprimante à ouvrir ici + No saved themes Aucun thème enregistré + No saved views (View → New View…) Aucune vue enregistrée (Affichage → Nouvelle vue…) + No Selection Aucune sélection + No selection Aucune sélection + No symbols in this document Aucun symbole dans ce document + No text selected Aucun texte sélectionné + No Thumbnails Sans vignettes + Noise Bruit + Nonbreaking Hyphen Trait d’union insécable + Nonbreaking Space Espace insécable + None Aucun + None (/) Aucun (/) + None (keep transparency) Aucun (conserver la transparence) + None. Aucun. + Normal Normal + Normal Screen Mode Mode Écran standard + Note: Remarque : + Nothing matches the Show filter. Aucun élément ne correspond au filtre Afficher. + Nothing to print Rien à imprimer + Nudge Décaler + Nudge in Perspective Décaler en perspective + Number of Artboards: Nombre de plans de travail : + Number of Colors: Nombre de couleurs : + Number of Recent Files to Display Nombre de fichiers récents à afficher + Number of Recent Fonts Nombre de polices récentes + Numbers Without Units Are Points Les nombres sans unité sont en points + Object Objet + Object Highlighting Mise en évidence des objets + Object IDs: ID d’objet : + Object Name Nom de l’objet + Object Properties Propriétés de l’objet + Object Selection by Path Only Sélection d’objet par tracé uniquement + Objects Objets + Objects using them keep their look but are no longer linked. Les objets qui les utilisent conservent leur apparence mais ne sont plus liés. + of characters used des caractères utilisés + of {count} sur {count} + Off Désactivé + Offset Path Décalage + Offset Path… Décalage… + Offset X Décalage X + Offset: Décalage : + OK OK + On Activé + On Copy À la copie + On Paste Au collage + On Screen À l’écran + One file per artboard: -.{ext} Un fichier par plan de travail : -.{ext} + One page per artboard. Une page par plan de travail. + One Point Un point + One Point Perspective Perspective à un point + One Point Perspective Preset 1 Paramètre prédéfini de perspective à un point 1 + One Point Perspective Preset 2 Paramètre prédéfini de perspective à un point 2 + One Point Perspective Preset 3 Paramètre prédéfini de perspective à un point 3 + One Point Perspective Preset 4 Paramètre prédéfini de perspective à un point 4 + One Point Perspective Preset 5 Paramètre prédéfini de perspective à un point 5 + Only Web Colors Couleurs Web uniquement + Opacity Opacité + Opacity & Mask Define Knockout Shape L’opacité et le masque définissent la forme de masquage + Opacity Mask Masque d’opacité + Opacity Mask Info Infos sur le masque d’opacité + Opacity Mask Options Options de masque d’opacité + Opacity: Opacité : + Opacity: 50% Multiply Opacité : 50 % Produit + Opacity: Default Opacité : par défaut + Opacity: {value} Opacité : {value} + Opaque Opaque + Open Ouvrir + Open a document to edit its patterns. Ouvrez un document pour modifier ses motifs. + Open a document to navigate it. Ouvrez un document pour y naviguer. + Open a document to preview its flattening. Ouvrez un document pour prévisualiser son aplatissement. + Open a document to preview its pages Ouvrez un document pour prévisualiser ses pages + Open a document to see its artboards. Ouvrez un document pour voir ses plans de travail. + Open a document to see its graphic styles. Ouvrez un document pour voir ses styles graphiques. + Open a document to see its history. Ouvrez un document pour voir son historique. + Open a document to see its swatches. Ouvrez un document pour voir son nuancier. + Open Document Ouvrir un document + Open Documents As Tabs Ouvrir les documents dans des onglets + Open Graphic Style Library Ouvrir une bibliothèque de styles graphiques + Open Location after Export Ouvrir l’emplacement après l’exportation + Open password: Mot de passe d’ouverture : + Open Paths Tracés ouverts + Open Recent File 1 Ouvrir le fichier récent 1 + Open Recent File 10 Ouvrir le fichier récent 10 + Open Recent File 11 Ouvrir le fichier récent 11 + Open Recent File 12 Ouvrir le fichier récent 12 + Open Recent File 13 Ouvrir le fichier récent 13 + Open Recent File 14 Ouvrir le fichier récent 14 + Open Recent File 15 Ouvrir le fichier récent 15 + Open Recent File 16 Ouvrir le fichier récent 16 + Open Recent File 17 Ouvrir le fichier récent 17 + Open Recent File 18 Ouvrir le fichier récent 18 + Open Recent File 19 Ouvrir le fichier récent 19 + Open Recent File 2 Ouvrir le fichier récent 2 + Open Recent File 20 Ouvrir le fichier récent 20 + Open Recent File 21 Ouvrir le fichier récent 21 + Open Recent File 22 Ouvrir le fichier récent 22 + Open Recent File 23 Ouvrir le fichier récent 23 + Open Recent File 24 Ouvrir le fichier récent 24 + Open Recent File 25 Ouvrir le fichier récent 25 + Open Recent File 26 Ouvrir le fichier récent 26 + Open Recent File 27 Ouvrir le fichier récent 27 + Open Recent File 28 Ouvrir le fichier récent 28 + Open Recent File 29 Ouvrir le fichier récent 29 + Open Recent File 3 Ouvrir le fichier récent 3 + Open Recent File 30 Ouvrir le fichier récent 30 + Open Recent File 4 Ouvrir le fichier récent 4 + Open Recent File 5 Ouvrir le fichier récent 5 + Open Recent File 6 Ouvrir le fichier récent 6 + Open Recent File 7 Ouvrir le fichier récent 7 + Open Recent File 8 Ouvrir le fichier récent 8 + Open Recent File 9 Ouvrir le fichier récent 9 + Open Recent Files Ouvrir les fichiers récents + Open Swatch Library Ouvrir une bibliothèque de nuances + Open the URL in the web browser Ouvrir l’URL dans le navigateur web + Opened with {} note(s): {} Ouvert avec {} remarque(s) : {} + Open… Ouvrir… + Optical Margin Alignment Alignement optique des marges + Optimize for fast web view Optimiser pour un affichage web rapide + Optimized Optimisé + Options Options + Options for Selection… Options de la sélection… + Options: Options : + Options… Options… + or more spaces with a tab espaces ou plus par une tabulation + Order: Ordre : + Ordinals Ordinaux + Orientation Orientation + Orientation: Orientation : + Original Original + Original: click to restore Original : cliquez pour restaurer + Other Autre + Other Layers Autres calques + Other Libraries Autres bibliothèques + Other Library… Autre bibliothèque… + Other Script… Autre script… + Out of Gamut Warning Avertissement hors gamme + Out of Web Color Warning Avertissement de couleur non Web + Out Tangent Handles Poignées de tangente sortantes + Outer Glow… Lueur externe… + Outline Tracés + Outline Others Autres en mode Tracés + Outline Stroke Vectoriser le contour + Outline Text Vectoriser le texte + Outlines and Rules Contours et filets + Output Sortie + Output All Blacks Accurately Rendre tous les noirs avec précision + Output All Blacks as Rich Black Rendre tous les noirs en noir riche + Output Black Noir de sortie + Output condition: Condition de sortie : + Output Intent Mode de sortie + Output intent profile: Profil du mode de sortie : + Output White Blanc de sortie + Output › Mode Sortie › Mode + Output: Sortie : + Overlap: Chevauchement : + Overlapping Superposés + Overlay Incrustation + Overprint Surimpression + Overprint and Transparency Flattener Surimpression et aplatissement des transparences + Overprint and Transparency Flattener Options Options de surimpression et d’aplatissement des transparences + Overprint Black… Surimpression du noir… + Overprint Fill Surimpression du fond + Overprint Preview Aperçu de la surimpression + Overprint Preview (separations) Aperçu de la surimpression (séparations) + Overprint Stroke Surimpression du contour + Overprint: Surimpression : + Overprints: Surimpressions : + Override Character Color Remplacer la couleur des caractères + Package Assembler + Package collects a saved document with its linked files and fonts. Assembler rassemble un document enregistré avec ses fichiers liés et ses polices. + Package created Assemblage créé + Packaged {n} file(s) in {folder}{note} {n} fichier(s) assemblé(s) dans {folder}{note} + Packaged {n} file(s){note} {n} fichier(s) assemblé(s){note} + Package… Assembler… + Page Page + Page information Informations de page + Page Isolated Blending Fusion isolée de la page + Page Knockout Group Groupe de masquage de la page + Pages Pages + Paintbrush Pinceau + Paintbrush Tool Outil Pinceau + Panel menu Menu du panneau + Panel Options… Options du panneau… + Panels Panneaux + Paragraph Paragraphe + Paragraph Style Style de paragraphe + Paragraph Style Options Options de style de paragraphe + Paragraph Styles Styles de paragraphe + Paragraph Symbol Pied-de-mouche + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. Fait partie d’ArtCraft. MIT OR Apache-2.0. Polices : Source Sans 3, Inter, JetBrains Mono (OFL). Icônes : Lucide (ISC) + VectorCraft. + Password Mot de passe + Paste Coller + Paste in Back Coller à l’arrière-plan + Paste in Front Coller au premier plan + Paste in Place Coller à la même position + Paste Offset Décalage au collage + Paste on All Artboards Coller sur tous les plans de travail + Paste Remembers Layers Coller en conservant les calques + Paste without Formatting Coller sans mise en forme + Pasted Collé + Pastels Pastels + Path Tracé + Path Eraser Gomme de tracé + Path Eraser Tool Outil Gomme de tracé + Pathfinder Pathfinder + Pathfinder Options… Options Pathfinder… + Pathfinders: Pathfinders : + Paths Tracés + Paths: {paths} Anchors: {anchors} Colors: {colors} Tracés : {paths} Points d’ancrage : {anchors} Couleurs : {colors} + Pattern Motif + Pattern Editing Modification du motif + Pattern Options Options de motif + Pattern options are available in pattern editing mode. Les options de motif sont disponibles en mode d’édition de motif. + PDF PDF + PDF 1.3 files have no transparency: the preset flattens it. Les fichiers PDF 1.3 ne gèrent pas la transparence : le paramètre prédéfini l’aplatit. + PDF Info Infos PDF + PDF Preset Paramètre prédéfini PDF + PDF Preset: Paramètre prédéfini PDF : + PDF Presets Paramètres prédéfinis PDF + PDF Presets… Paramètres prédéfinis PDF… + PDF Settings Réglages PDF + PDF/A files carry their own output intent. Les fichiers PDF/A contiennent leur propre mode de sortie. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. Les fichiers PDF/X-1a sont en CMJN : chaque couleur est convertie vers la destination (vide : le profil CMJN du mode de sortie), sans balise. + Pen Tool Outil Plume + Pencil Crayon + Pencil Tool Outil Crayon + per slice par tranche + Percent: Pourcentage : + Perceptual Scales Échelles perceptives + Performance Performances + Performance & Storage Performances et stockage + Permissions Autorisations + Permissions password: Mot de passe des autorisations : + Perspective Perspective + Perspective Grid Grille de perspective + Perspective Grid Options Options de grille de perspective + Perspective Grid Preset Paramètre prédéfini de grille de perspective + Perspective Grid Presets Paramètres prédéfinis de grille de perspective + Perspective grid presets Paramètres prédéfinis de grille de perspective + Perspective Grid Presets… Paramètres prédéfinis de grille de perspective… + Perspective Grid Settings Réglages de la grille de perspective + Perspective Grid Tool Outil Grille de perspective + Perspective Plane Options… Options du plan de perspective… + Perspective Selection Tool Outil Sélection de perspective + Phone 390×844 Téléphone 390×844 + Picas Picas + Pie Graph Tool Outil Graphe à secteurs + Pixel Preview Aperçu des pixels + Pixels Pixels + Place Importer + Place DXF Importer un fichier DXF + Place onto a new locked, dimmed template layer below the current layer Importer dans un nouveau calque modèle verrouillé et estompé sous le calque actif + Place PDF Importer un fichier PDF + Place Symbol Instance Placer une occurrence de symbole + Place the pages with the placement again Importer de nouveau les pages avec le même placement + Placed File Info Informations sur le fichier importé + Placement Options Options de placement + Placement Options… Options de placement… + Placement: Placement : + Place… Importer… + Plates Plaques + Platform: Plateforme : + Play Current Selection Exécuter la sélection actuelle + Plug-in Info Informations sur le module externe + Plug-ins Modules externes + Plug-in… Module externe… + Point Sample Échantillon ponctuel + Point Text Objects Objets texte de point + Points Points + Points: clicks add free points Points : chaque clic ajoute des points libres + Polar Grid Grille à coordonnées polaires + Polar Grid Tool Outil Grille à coordonnées polaires + Polygon Polygone + Polygon Properties: Propriétés du polygone : + Polygon Tool Outil Polygone + Portrait Portrait + Power Saving (integrated) Économie d’énergie (intégrée) + Portuguese Portugais + Position Position + Postcard Carte postale + PostScript: PostScript : + PPI ppp + Preferences Préférences + Preferences… Préférences… + Prefix: Préfixe : + Presentation Attributes Attributs de présentation + Presentation Mode Mode Présentation + Preserve Alpha Transparency Conserver la transparence alpha + Preserve Appearance and Overprints Conserver l’aspect et les surimpressions + Preserve CMYK Numbers Conserver les valeurs CMJN + Preserve Editing Capabilities Conserver les fonctions d’édition + Preserve editing capabilities Conserver les fonctions d’édition + Preserve Lightness Conserver la luminosité + Preserve Overprints and Spot Colors Conserver les surimpressions et les tons directs + Preserve Paths Conserver les tracés + Preserve Shape Using: Conserver la forme avec : + Preserve Spot Colors Conserver les tons directs + Preserve Text Appearance Conserver l’aspect du texte + Preserve Text Editability Conserver la modifiabilité du texte + Preserve: Conserver : + PRESET DETAILS DÉTAILS DU PARAMÈTRE PRÉDÉFINI + Preset Name Nom du paramètre prédéfini + Preset name: Nom du paramètre prédéfini : + Preset {} Paramètre prédéfini {} + Preset: Paramètre prédéfini : + Presets you save with the Save Preset button appear here. Les paramètres prédéfinis enregistrés avec le bouton Enregistrer le paramètre prédéfini apparaissent ici. + Presets: Paramètres prédéfinis : + Press Appuyez sur + Preview Aperçu + Preview All Layers Aperçu de tous les calques + Preview in Browser Aperçu dans le navigateur + Preview in Browser needs the desktop app (the web downloads files instead) L’aperçu dans le navigateur nécessite l’application de bureau (la version web télécharge les fichiers à la place) + Preview Mode Mode Aperçu + Preview the document as it is now Afficher un aperçu du document dans son état actuel + Previous Précédent + Previous Library Bibliothèque précédente + Previous Page Page précédente + Previous page Page précédente + Primary Principal + Print Imprimer + Print as Bitmap Imprimer en tant que bitmap + Print Layers: Calques à imprimer : + Print Preset Paramètre prédéfini d’impression + Print Preset: Paramètre prédéfini d’impression : + Print Presets Paramètres prédéfinis d’impression + Print presets Paramètres prédéfinis d’impression + Print Presets… Paramètres prédéfinis d’impression… + Print Preview Aperçu avant impression + Print Setup Configuration de l’impression + Print Tiling Mosaïque d’impression + Print Tiling Pages Pages de la mosaïque d’impression + Print Tiling Tool Outil Mosaïque d’impression + Printer Imprimante + Printer mark type: Type de repères d’impression : + Printer profile: Profil de l’imprimante : + Printer Setup… Configuration de l’imprimante… + Printer: Imprimante : + Printers Imprimantes + Printing / Exporting Impression / Exportation + Printing allowed: Impression autorisée : + Print… Imprimer… + Process Color Couleur quadrichromique + Profile inclusion: Inclusion du profil : + Profile: Profil : + Progressive Progressif + Projecting Cap Extrémité en saillie + Proof Colors Couleurs d’épreuve + Proof Setup Format d’épreuve + Properties Propriétés + Proportional Oldstyle Elzévirien proportionnel + Pucker & Bloat… Contracter et dilater… + Pucker Tool Outil Contraction + Punctuation Ponctuation + Punctuation Space Espace de ponctuation + Puppet Warp Déformation de la marionnette + Puppet Warp Pins Épingles de déformation de la marionnette + Puppet Warp Tool Outil Déformation de la marionnette + Quality {quality} Qualité {quality} + Quality: Qualité : + Quarter Space Quart d’espace + Quick Actions Actions rapides + Quickly start a new file Commencer rapidement un nouveau fichier + Quit VectorCraft Quitter VectorCraft + Radar Graph Tool Outil Graphe radar + Radial Radial + Radial Glows Halos radiaux + Radial Gradient Dégradé radial + Radius 1 Rayon 1 + Radius 2 Rayon 2 + Radius1 Rayon1 + Radius2 Rayon2 + Rainbow Arc-en-ciel + Random Aléatoire + Randomize Colors Couleurs aléatoires + Randomly change color order Modifier aléatoirement l’ordre des couleurs + Randomly change saturation and brightness Modifier aléatoirement la saturation et la luminosité + Range: Plage : + Raster Effects Effets de pixellisation + Raster File Format: Format du fichier pixellisé : + Raster Sample Size: Taille de l’échantillon pixellisé : + Raster/Vector Balance: Équilibre pixellisé/vectoriel : + Rasterize Pixelliser + Rasterize Unsupported Art Pixelliser les illustrations non prises en charge + Rasterize… Pixelliser… + Rasters Images pixellisées + Rating: Classement : + Real World: Monde réel : + Real-time Drawing and Editing Dessin et édition en temps réel + Rearrange All Artboards (on the roadmap) Réorganiser tous les plans de travail (prévu) + Rearrange All Artboards… Réorganiser tous les plans de travail… + Recall Selection Rappeler la sélection + RECENT RÉCENTS + Recent Récents + Recent Colors Couleurs récentes + Recent File Fichier récent + Recent Files Fichiers récents + Recent Font Police récente + Recent Font 1 Police récente 1 + Recent Font 10 Police récente 10 + Recent Font 11 Police récente 11 + Recent Font 12 Police récente 12 + Recent Font 13 Police récente 13 + Recent Font 14 Police récente 14 + Recent Font 15 Police récente 15 + Recent Font 2 Police récente 2 + Recent Font 3 Police récente 3 + Recent Font 4 Police récente 4 + Recent Font 5 Police récente 5 + Recent Font 6 Police récente 6 + Recent Font 7 Police récente 7 + Recent Font 8 Police récente 8 + Recent Font 9 Police récente 9 + Recent Fonts Polices récentes + Recent URLs URL récentes + Recolor Recolorier + Recolor Artwork Recolorier la composition + Recolor Artwork… Recolorier la composition… + Recolor this row Recolorier cette ligne + Recolor with Preset Recolorier avec un paramètre prédéfini + Recover Documents Récupérer les documents + Recovery Data Données de récupération + Rectangle Rectangle + Rectangle Cut Découpe rectangulaire + Rectangle Cut Tool Outil Découpe rectangulaire + Rectangle Properties: Propriétés du rectangle : + Rectangle Tool Outil Rectangle + Rectangle: Sunshine Rectangle : soleil + Rectangle… Rectangle… + Rectangular Grid Grille rectangulaire + Rectangular Grid Tool Outil Grille rectangulaire + Red Rouge + Redefine Character Style Redéfinir le style de caractère + Redefine Graphic Style Redéfinir le style graphique + Redefine Graphic Style “{name}” Redéfinir le style graphique « {name} » + Redefine Paragraph Style Redéfinir le style de paragraphe + Redefine Symbol Redéfinir le symbole + Redefine {kind} Style Redéfinir le style de {kind} + Redo Rétablir + Redo {name} Rétablir {name} + Redo {} Rétablir {} + Reduce Colors Réduire les couleurs + Reduce to Basic Appearance Réduire à l’aspect de base + Reduction: Réduction : + Reference Point Point de référence + Reflect Miroir + Reflect Horizontal Miroir horizontal + Reflect Tool Outil Miroir + Reflect X Miroir X + Reflect Y Miroir Y + Reflect… Miroir… + Refresh Actualiser + Refresh Font List Actualiser la liste des polices + Registered Trademark Symbol Symbole de marque déposée + Registration marks Repères de montage + Registry name: Nom du registre : + Regular Normal + Release Libérer + Release Compound Shape Libérer la forme composée + Release Guides Libérer les repères + Release Opacity Mask Annuler le masque d’opacité + Release Selection Libérer la sélection + Release to Layers (Build) Diviser en calques (cumul) + Release to Layers (Sequence) Diviser en calques (séquence) + Release with Perspective Libérer avec la perspective + Relink Lier à nouveau + Relink Linked Files to the Document Lier à nouveau les fichiers liés au document + Relink to Folder… Lier à nouveau à un dossier… + Relinked {n} image(s) {n} image(s) liée(s) à nouveau + Relinked {n} image(s); not in that folder: {} {n} image(s) liée(s) à nouveau ; absentes de ce dossier : {} + Relinked {} image(s) {} image(s) liée(s) à nouveau + Relink… Lier à nouveau… + Reload Plug-ins Recharger les modules externes + Remove Supprimer + Remove Anchor Points Supprimer les points d’ancrage + Remove Asset Supprimer la ressource + Remove blank lines between paragraphs Supprimer les lignes vides entre les paragraphes + Remove Brush Stroke Supprimer la forme de contour + Remove Effect Supprimer l’effet + Remove Guide Supprimer le repère + Remove Item Supprimer l’élément + Remove Plug-in Supprimer le module externe + Remove Selected Assets Supprimer les ressources sélectionnées + Remove Threading Supprimer le chaînage + Remove “{keyword}” Supprimer « {keyword} » + Rename Renommer + Rename Asset Renommer la ressource + Rename Character Style Renommer le style de caractère + Rename Graphic Style Renommer le style graphique + Rename Paragraph Style Renommer le style de paragraphe + Rename Workspace Renommer l’espace de travail + Render Threads (-1 = Automatic) Threads de rendu (-1 = automatique) + Rendering intent: Mode de rendu : + Reorder Réorganiser + Reorder Appearance Item Réorganiser l’élément d’aspect + Repeat Répétition + Repeat Options Options de répétition + Repeat Pathfinder Répéter Pathfinder + Repeat Tab Répéter la tabulation + Repeat {name} Répéter {name} + Replace Remplacer + Replace Font Remplacer la police + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Remplacez-le par un autre fichier ou ignorez-le : ses images continueront d’afficher l’aperçu enregistré avec le document. + Replace Spine Remplacer la colonne vertébrale + Replace Symbol Remplacer le symbole + Replace With Font Remplacer par la police + Replaces the saved preset of that name. Remplace le paramètre prédéfini enregistré sous ce nom. + Replace… Remplacer… + Require a password to open the document Exiger un mot de passe pour ouvrir le document + Reselect Sélectionner à nouveau + Reset Réinitialiser + Reset Bounding Box Réinitialiser le cadre de sélection + Reset Envelope Mesh Réinitialiser le filet de l’enveloppe + Reset Envelope Shape Réinitialiser la forme de l’enveloppe + Reset Essentials Réinitialiser Essentials + Reset Keyboard Shortcuts Réinitialiser les raccourcis clavier + Reset OpenType Features Réinitialiser les fonctions OpenType + Reset Panel Réinitialiser le panneau + Reset Preferences Réinitialiser les préférences + Reset Profiles Réinitialiser les profils + Reset Rotate View Réinitialiser la rotation de la vue + Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration + Reset to Default Rétablir les valeurs par défaut + Reset to White, Black Réinitialiser à blanc, noir + Reset with Mesh Réinitialiser avec un filet + Reset with Mesh… Réinitialiser avec un filet… + Reset with Warp Réinitialiser avec une déformation + Reset with Warp… Réinitialiser avec une déformation… + Reset {name} Réinitialiser {name} + Reset {} Réinitialiser {} + Reshape Modelage + Reshape Segment Modeler le segment + Reshape Tool Outil Modelage + Reshape Type Area Modeler la zone de texte + Resolution: Résolution : + Responsive Adaptatif + Restore Restaurer + Restore every preference to its default (applied on OK) Rétablir toutes les préférences à leur valeur par défaut (appliqué avec OK) + Restore Recovered Documents Restaurer les documents récupérés + Restored documents open unsaved: save them to keep them. Les documents restaurés s’ouvrent sans être enregistrés : enregistrez-les pour les conserver. + Restored {} recovered document(s): save them to keep them {} document(s) récupéré(s) restauré(s) : enregistrez-les pour les conserver + Restrict printing, editing and other tasks Restreindre l’impression, la modification et d’autres tâches + Reverse Front to Back Inverser l’avant et l’arrière + Reverse Gradient Inverser le dégradé + Reverse Order Inverser l’ordre + Reverse Path Direction Inverser le sens du tracé + Reverse Path Direction Off Inversion du sens du tracé désactivée + Reverse Path Direction On Inversion du sens du tracé activée + Reverse Spine Inverser la colonne vertébrale + Revert Version précédente + Revert to the saved version of “{name}”? Revenir à la version enregistrée de « {name} » ? + Revolve… Révolution… + RGB Color Couleur RVB + Right Droite + Right Grid: Grille de droite : + Right in Front Droite devant + Right Indent Retrait droit + Right Plane Plan de droite + Right-Justified Tab Tabulation alignée à droite + Right-to-Left Paragraph Direction Sens de paragraphe de droite à gauche + Right: Droite : + Rise… Élévation… + Roman Baseline Ligne de base romaine + Roman Hanging Punctuation Ponctuation romaine hors justification + Rotate Rotation + Rotate 90° CW Rotation de 90° horaire + Rotate Tool Outil Rotation + Rotate View Tool Outil Faire pivoter la vue + Rotate… Rotation… + Rotation Rotation + Roughen… Distorsion grossière… + Round Cap Extrémité arrondie + Round Corners… Arrondis… + Round Join Raccord arrondi + Rounded Arrondi + Rounded Rectangle Rectangle arrondi + Rounded Rectangle Tool Outil Rectangle arrondi + Rounded Rectangle… Rectangle arrondi… + Row Size: Taille des lignes : + Rows: Lignes : + Rule: Filet : + Rulers Règles + Rulers & Grids Règles et grilles + Run Plug-in Exécuter le module externe + Same Identique + Same As Source Identique à la source + Sample Color Prélever la couleur + Sans Serif / Gothic Sans empattement / gothique + Saturate Saturation + Saturate… Saturation… + Saturation Saturation + Save Enregistrer + Save a Copy Enregistrer une copie + Save a Copy… Enregistrer une copie… + Save As Enregistrer sous + Save as PDF… Enregistrer au format PDF… + Save as preset: Enregistrer comme paramètre prédéfini : + Save as Template Enregistrer comme modèle + Save as Template… Enregistrer comme modèle… + Save As… Enregistrer sous… + Save changes to {name} Enregistrer les modifications de {name} + Save color group to Swatch panel Enregistrer le groupe de couleurs dans le panneau Nuancier + Save Colors as Swatches Enregistrer les couleurs comme nuances + Save Document Enregistrer le document + Save Document Info… Enregistrer les informations du document… + Save each artboard to a separate file Enregistrer chaque plan de travail dans un fichier distinct + Save for Office Documents Enregistrer pour Microsoft Office + Save for Office Documents… Enregistrer pour Microsoft Office… + Save for Web Enregistrer pour le web + Save for Web (Legacy)… Enregistrer pour le web (hérité)… + Save for Web Presets Paramètres prédéfinis d’Enregistrer pour le web + Save for Web Preview Aperçu d’Enregistrer pour le web + Save for Web Settings Réglages d’Enregistrer pour le web + Save Graphic Style Library Enregistrer la bibliothèque de styles graphiques + Save Graphic Style Library… Enregistrer la bibliothèque de styles graphiques… + Save Grid as Preset Enregistrer la grille comme paramètre prédéfini + Save Grid as Preset… Enregistrer la grille comme paramètre prédéfini… + Save PDF Enregistrer le PDF + Save PDF Dialog Boîte de dialogue Enregistrer le PDF + Save PDF Preset Enregistrer le paramètre prédéfini PDF + Save Perspective Grid Preset Enregistrer le paramètre prédéfini de grille de perspective + Save Preset Enregistrer le paramètre prédéfini + Save Preset… Enregistrer le paramètre prédéfini… + Save Print Preset Enregistrer le paramètre prédéfini d’impression + Save Recovery Data Enregistrer les données de récupération + Save Save for Web Preset Enregistrer le paramètre prédéfini d’Enregistrer pour le web + Save selected colors as swatches Enregistrer les couleurs sélectionnées comme nuances + Save Selection… Enregistrer la sélection… + Save Swatch Library Enregistrer la bibliothèque de nuances + Save Swatch Library… Enregistrer la bibliothèque de nuances… + Save the current layout under this name Enregistrer la disposition actuelle sous ce nom + Save the document first? Enregistrer d’abord le document ? + Save the selected preset to a file Enregistrer le paramètre prédéfini sélectionné dans un fichier + Save the settings as a preset Enregistrer les réglages comme paramètre prédéfini + Save Theme Enregistrer le thème + Save theme to My Themes Enregistrer le thème dans Mes thèmes + Save these settings as a preset Enregistrer ces réglages comme paramètre prédéfini + Save these settings as a print preset Enregistrer ces réglages comme paramètre prédéfini d’impression + Save to Swatches Enregistrer dans le nuancier + Save To: Enregistrer dans : + Save Transparency Flattener Preset Enregistrer le paramètre prédéfini d’aplatissement des transparences + Save Transparency Flattener Preset… Enregistrer le paramètre prédéfini d’aplatissement des transparences… + SAVED ENREGISTRÉS + Saved Enregistré + Saved recovery data for {title} Données de récupération enregistrées pour {title} + Saved Selection 1 Sélection enregistrée 1 + Saved Selection 10 Sélection enregistrée 10 + Saved Selection 11 Sélection enregistrée 11 + Saved Selection 12 Sélection enregistrée 12 + Saved Selection 13 Sélection enregistrée 13 + Saved Selection 14 Sélection enregistrée 14 + Saved Selection 15 Sélection enregistrée 15 + Saved Selection 16 Sélection enregistrée 16 + Saved Selection 17 Sélection enregistrée 17 + Saved Selection 18 Sélection enregistrée 18 + Saved Selection 19 Sélection enregistrée 19 + Saved Selection 2 Sélection enregistrée 2 + Saved Selection 20 Sélection enregistrée 20 + Saved Selection 21 Sélection enregistrée 21 + Saved Selection 22 Sélection enregistrée 22 + Saved Selection 23 Sélection enregistrée 23 + Saved Selection 24 Sélection enregistrée 24 + Saved Selection 25 Sélection enregistrée 25 + Saved Selection 3 Sélection enregistrée 3 + Saved Selection 4 Sélection enregistrée 4 + Saved Selection 5 Sélection enregistrée 5 + Saved Selection 6 Sélection enregistrée 6 + Saved Selection 7 Sélection enregistrée 7 + Saved Selection 8 Sélection enregistrée 8 + Saved Selection 9 Sélection enregistrée 9 + Saved Selections Sélections enregistrées + Saved to User Defined: {} Enregistré dans Défini par l’utilisateur : {} + Saved View 1 Vue enregistrée 1 + Saved View 10 Vue enregistrée 10 + Saved View 2 Vue enregistrée 2 + Saved View 3 Vue enregistrée 3 + Saved View 4 Vue enregistrée 4 + Saved View 5 Vue enregistrée 5 + Saved View 6 Vue enregistrée 6 + Saved View 7 Vue enregistrée 7 + Saved View 8 Vue enregistrée 8 + Saved View 9 Vue enregistrée 9 + Saved Views Vues enregistrées + Saved {n} files beside {path} {n} fichiers enregistrés à côté de {path} + Saved {path} {path} enregistré + Saved {path} with {} note(s): {first} {path} enregistré avec {} remarque(s) : {first} + Saves the current bars, toolbar and panel layout. Enregistre la disposition actuelle des barres, de la barre d’outils et des panneaux. + Save… Enregistrer… + Saving Enregistrement + Saving Poster.vectorcraft Enregistrement de Poster.vectorcraft + Saving recovery data for {} Enregistrement des données de récupération de {} + Saving {} Enregistrement de {} + Scale Mise à l’échelle + Scale Corners Mettre à l’échelle les angles + Scale Cursor Proportional to UI Mettre le curseur à l’échelle de l’interface + Scale Lineweights Mettre à l’échelle les épaisseurs de trait + Scale Strokes & Effects Mettre à l’échelle les contours et les effets + Scale to Fit Artboard Mettre à l’échelle pour ajuster au plan de travail + Scale Tool Outil Mise à l’échelle + Scale: Échelle : + Scale… Mise à l’échelle… + Scaling Mise à l’échelle + Scaling: Mise à l’échelle : + Scallop Tool Outil Festons + Scans: Analyses : + Scatter Dispersion + Scatter Graph Tool Outil Graphe de dispersion + Scissors Tool Outil Ciseaux + Scratch Disks Disques de travail + Screen Superposition + Screen (72 ppi) Écran (72 ppp) + Screen Mode Mode d’affichage + Screens Écrans + Scribble… Griffonnage… + Script / Brush Manuscrite / Pinceau + Scripts Scripts + Search Rechercher + Search All Rechercher partout + Search commands and tools Chercher commandes et outils + Search commands and tools… Chercher commandes et outils… + Search Commands… Rechercher des commandes… + Secondary Secondaire + Section Symbol Symbole de section + Security Sécurité + Select Sélection + Select a text object Sélectionnez un objet texte + Select a text object to edit its character attributes. Sélectionnez un objet texte pour modifier ses attributs de caractère. + Select a text object to edit its paragraph attributes. Sélectionnez un objet texte pour modifier ses attributs de paragraphe. + Select a text object to set its tab stops. Sélectionnez un objet texte pour définir ses taquets de tabulation. + Select All Tout sélectionner + Select All Art on Layer Sélectionner toute l’illustration du calque + Select All Assets Sélectionner toutes les ressources + Select All Instances Sélectionner toutes les occurrences + Select All Pins Sélectionner toutes les épingles + Select All Unused Sélectionner tous les éléments inutilisés + Select an image to see its Link Info. Sélectionnez une image pour afficher les informations sur son lien. + Select an image to trace Sélectionnez une image à vectoriser + Select Anchors Sélectionner les points d’ancrage + Select Appearance Item Sélectionner un élément d’aspect + Select Art Sélectionner l’illustration + Select art and choose Object › Pattern › Make. Sélectionnez une illustration et choisissez Objet › Motif › Créer. + Select art and click New Symbol to make one. Sélectionnez une illustration et cliquez sur Nouveau symbole pour en créer un. + Select art and use New Brush to make one. Sélectionnez une illustration et utilisez Nouvelle forme pour en créer une. + Select Freeform Point Sélectionner un point libre + Select Gradient Stop Sélectionner un point de dégradé + Select Guides Sélectionner les repères + Select Objects Sélectionner des objets + Select objects to see their CSS, or Generate CSS for the whole document. Sélectionnez des objets pour afficher leur CSS, ou utilisez Générer le CSS pour tout le document. + Select Same Tint % Sélectionner le même % de teinte + Select Slices Sélectionner les tranches + Select styled art and click New Graphic Style. Sélectionnez une illustration stylée et cliquez sur Nouveau style graphique. + Select text Sélectionnez du texte + Select Text by Font Sélectionner le texte par police + Select text or click into text with the Type tool to insert glyphs Sélectionnez du texte ou cliquez dans un texte avec l’outil Texte pour insérer des glyphes + Select text to set its OpenType features. Sélectionnez du texte pour définir ses fonctions OpenType. + Select the art (and, on top of it, the mask object) Sélectionnez l’illustration (et, au-dessus, l’objet masque) + Select Tool Sélectionner un outil + Select: Sélectionner : + Selected Artwork Illustration sélectionnée + Selected Styles Only Styles sélectionnés uniquement + Selected Swatches Nuances sélectionnées + Selected Swatches Only Nuances sélectionnées uniquement + Selection Sélection + Selection & Anchor Display Affichage de la sélection et des points d’ancrage + Selection Only Sélection uniquement + Selection Tool Outil Sélection + Send Backward En arrière + Send to Back Arrière-plan + Send to Current Layer Envoyer sur le calque actif + Sentence case Majuscule en début de phrase + Separate colors into rows Séparer les couleurs en lignes + Separate several keywords with commas. Séparez plusieurs mots-clés par des virgules. + Separations list the document's inks here: one page per ink that prints. Les séparations listent ici les encres du document : une page par encre imprimée. + Separations Preview Aperçu des séparations + Serialize Document Sérialiser le document + Serif / Mincho Serif / Mincho + Server-side (.map) Côté serveur (.map) + Set 1 Jeu 1 + Set Anchors Définir les points d’ancrage + Set base color Définir la couleur de base + Set base color to the current color Utiliser la couleur actuelle comme couleur de base + Set Bounds Définir les limites + Set Current Layer Définir le calque actif + Set Key Object Définir l’objet clé + Set Keyboard Shortcut Définir un raccourci clavier + Set Mesh Point Color Définir la couleur du point de filet + Set Preference Définir une préférence + Set Slice Rectangle Définir le rectangle de tranche + Set Tab Stops Définir les taquets de tabulation + Set Text Définir le texte + Set the selected color to the current color Remplacer la couleur sélectionnée par la couleur actuelle + Set Zoom Définir le zoom + Settings… Réglages… + Setup… Configuration… + Shadows and Glows Ombres et lueurs + Shape Forme + Shape Builder Concepteur de formes + Shape Builder Regions Zones du Concepteur de formes + Shape Builder Tool Outil Concepteur de formes + Shape Effects Effets de forme + Shape Modes: Modes de forme : + Shape Properties: Propriétés de la forme : + Shaper Tool Outil Shaper + Shapes Formes + Shear Distorsion + Shear Tool Outil Distorsion + Shear… Distorsion… + Shell Lower… Coquillage inférieur… + Shell Upper… Coquillage supérieur… + Shift Maj + Shift to Color… Décaler vers la couleur… + Shifts the tints of global and spot colors; other colors stay. Décale les teintes des couleurs globales et des tons directs ; les autres couleurs restent inchangées. + Show Afficher + Show Active Plane Widget Afficher le widget de plan actif + Show All Tout afficher + Show All Hidden Attributes Afficher tous les attributs masqués + Show All Layers Afficher tous les calques + Show All Plates Afficher toutes les plaques + Show all plates Afficher toutes les plaques + Show All Swatches Afficher toutes les nuances + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Afficher et imprimer d’après la définition Lab ; les fichiers PDF l’incluent comme couleur de remplacement de l’encre. + Show and separate through the working CMYK space, as in older documents. Afficher et séparer via l’espace de travail CMJN, comme dans les documents plus anciens. + Show Artboards Afficher les plans de travail + Show Bounding Box Afficher le cadre de sélection + Show Brush Size Afficher la taille de la forme + Show Center Afficher le centre + Show Code Afficher le code + Show Color Groups Afficher les groupes de couleurs + Show Color Swatches Afficher les nuances de couleur + Show Corner Widget Afficher le widget d’angle + Show East Asian Options Afficher les options d’Asie orientale + Show Edges Afficher les bords + Show Embedded Afficher les éléments incorporés + Show Find Field Afficher le champ de recherche + Show Font Names in English Afficher les noms de police en anglais + Show Gradient Annotator Afficher l’annotateur de dégradé + Show Gradient Swatches Afficher les nuances de dégradé + Show Grid Afficher la grille + Show Grid (⌘') Afficher la grille (⌘') + Show Guides Afficher les repères + Show handles when multiple anchors are selected Afficher les poignées lorsque plusieurs points d’ancrage sont sélectionnés + Show Hidden Characters Afficher les caractères masqués + Show Images in Outline Mode Afficher les images en mode Tracés + Show in Folder Afficher dans le dossier + Show Indic Options Afficher les options indiennes + Show Layers Only Afficher uniquement les calques + Show Link Info Afficher les informations sur le lien + Show List View Afficher en liste + Show Mesh Afficher le filet + Show Missing Afficher les éléments manquants + Show Modified Afficher les éléments modifiés + Show Note Afficher la note + Show Options Afficher les options + Show Package Afficher le dossier d’assemblage + Show Panel Afficher le panneau + Show Pattern Swatches Afficher les nuances de motif + Show Perspective Grid Afficher la grille de perspective + Show Pixel Grid (Above 600% Zoom) Afficher la grille de pixels (zoom supérieur à 600 %) + Show Print Tiling Afficher la mosaïque d’impression + Show Rulers Afficher les règles + Show Rulers (⌘R) Afficher les règles (⌘R) + Show Slice Numbers Afficher les numéros de tranche + Show Slices Afficher les tranches + Show Swatch Bounds Afficher les limites de la nuance + Show Swatch Kinds Afficher les types de nuance + Show Text Threads Afficher les liens de texte + Show The Home Screen When No Documents Are Open Afficher l’écran d’accueil lorsqu’aucun document n’est ouvert + Show Thumbnail Afficher la vignette + Show Thumbnail View Afficher en vignettes + Show Thumbnails Afficher les vignettes + Show Tile Edge Afficher le bord du carreau + Show Tints/Shades Afficher les teintes claires/foncées + Show Tool Tips Afficher les info-bulles + Show Transparency Grid Afficher la grille de transparence + Show Video Rulers Afficher les règles vidéo + Show Vivid/Muted Afficher vives/atténuées + Show Warm/Cool Afficher chaudes/froides + Show {kind} Brushes Afficher les formes de type {kind} + Show/hide plate (Alt-click: only this plate) Afficher/masquer la plaque (Alt+clic : cette plaque uniquement) + Show: Afficher : + Shown with the optimised preview. Affiché avec l’aperçu optimisé. + Side 1: Côté 1 : + Side 2: Côté 2 : + Sides: Côtés : + Simplify Simplifier + Simplify: Simplifier : + Simplify… Simplifier… + Simulate Colored Paper Simuler un papier de couleur + Simulate Paper Simuler le papier + Double Column Deux colonnes + Single Left Quotation Mark Guillemet simple ouvrant + Single Quotes: Guillemets simples : + Single Right Quotation Mark Guillemet simple fermant + Single-line Composer Compositeur ligne par ligne + Sixth Space Espace d’un sixième + Size Taille + Size Tile to Art Ajuster le carreau à l’illustration + Size/Leading Taille/interlignage + Size: Taille : + Size: {size} Tracking: {tracking} Taille : {size} Approche : {tracking} + Skew Inclinaison + Skin Tone Ramps Gammes de tons chair + Skip Blank Artboards Ignorer les plans de travail vides + Sky Gradients Dégradés de ciel + Slice Tranche + Slice Options Options de tranche + Slice Options… Options de tranche… + Slice Selection Tool Outil Sélection de tranche + Slice Tool Outil Tranche + Slice Type: Type de tranche : + Slices Tranches + slices across, evenly spaced tranches en largeur, régulièrement espacées + slices down, evenly spaced tranches en hauteur, régulièrement espacées + Slices: Tranches : + Small Petite + Small Caps Petites capitales + Small Caps (on the roadmap) Petites capitales (prévu dans la feuille de route) + Small Caps: Petites capitales : + Small List View Petite liste + Small Thumbnail View Petites vignettes + Small Thumbnails Petites vignettes + Smaller ↔ Larger Plus petit ↔ Plus grand + Smart Guides Repères commandés + Smart Guides (View → Smart Guides) Repères commandés (Affichage → Repères commandés) + Smart Guides (⌘U) Repères commandés (⌘U) + Smart Punctuation… Ponctuation typographique… + Smooth Lisser + Smooth Color Couleur lissée + Smooth Tool Outil Arrondi + Snap Curves To Lines Aimanter les courbes aux lignes + Snap Options Options de magnétisme + Snap to Glyph Aimanter au glyphe + Snap to Grid Aimanter à la grille + Snap to Pixel Aimanter au pixel + Snap to Point Aimanter au point + Snapping Tolerance Tolérance du magnétisme + Social Square Post 1080×1080 Publication carrée pour réseaux sociaux 1080×1080 + Soft Light Lumière tamisée + Solid Plein + Sort by Kind Trier par type + Sort by Name Trier par nom + Sort by Status Trier par état + Source code on GitHub Code source sur GitHub + Source Sans 3 Source Sans 3 + Space After Paragraph Espace après le paragraphe + Space Before Paragraph Espace avant le paragraphe + Spaces Espaces + Spacing Guides Repères d’espacement + Spacing: Espacement : + Spanish Espagnol + Specified Distance Distance définie + Specified Steps Nombre d’étapes défini + Specify: Définir : + Spectrum Gradients Dégradés de spectre + Spelling Orthographe + Spiral Spirale + Spiral Tool Outil Spirale + Split Freeform Line Scinder la ligne libre + Split Into Grid Diviser en grille + Split Into Grid… Diviser en grille… + Split Path Scinder le tracé + spot ton direct + Spot Color Ton direct + Spot Colors Tons directs + Spot Colors… Tons directs… + Spread: Étendue : + Square Carré + Squeeze… Compression… + Stacked Bar Graph Tool Outil Graphe à barres empilées + Stacked Column Graph Tool Outil Graphe à colonnes empilées + Stair Step En escalier + Standard Ligatures Ligatures standard + Standard Vertical Roman Alignment Alignement vertical standard des caractères romains + Standard: Standard : + Star Étoile + Star Tool Outil Étoile + Start arrowhead Pointe de flèche de début + Start Global Edit Commencer la modification globale + Status État + Steps: Étapes : + Stop Editing Opacity Mask Arrêter la modification du masque d’opacité + Stop editing the opacity mask Arrêter la modification du masque d’opacité + Stop Playing/Recording Arrêter la lecture/l’enregistrement + Stray Points Points isolés + Strikethrough Barré + Stroke Contour + Stroke (X), double-click for the Color Picker Contour (X), double-cliquez pour ouvrir le sélecteur de couleurs + Stroke Color Couleur du contour + Stroke Options Options de contour + Character: Caractère : + Character options Options de caractère + Stroke options Options de contour + Transform options Options de transformation + Stroke Weight Épaisseur du contour + Stroke: Contour : + Stroke: click for swatches, Shift-click for the color mixer Contour : cliquez pour le nuancier, Maj+clic pour le mélangeur de couleurs + Style Style + Style Attributes Attributs de style + Style Attributes (Entity References) Attributs de style (références d’entité) + Style Name: Nom du style : + Style: Style : + Styling: Stylisation : + Stylize Esthétiques + Subdivisions Subdivisions + Subscript Indice + Subscript (on the roadmap) Indice (prévu dans la feuille de route) + Subscript: Indice : + Subset fonts below: Sous-ensemble de polices en deçà de : + Subtract Soustraire + Suffix Suffixe + Summary Résumé + Superscript Exposant + Superscript (on the roadmap) Exposant (prévu dans la feuille de route) + Superscript: Exposant : + SVG Filters Filtres SVG + SVG Interactivity Interactivité SVG + SVG Options Options SVG + SVG Profile: Profil SVG : + Swap Fill and Stroke Permuter le fond et le contour + Swap Fill and Stroke (Shift+X) Permuter le fond et le contour (Maj+X) + Swap start and end arrowheads Permuter les pointes de flèche de début et de fin + Swap the selected object for the file, keeping its place and transform Remplacer l’objet sélectionné par le fichier, en conservant sa position et sa transformation + Swash Lettres ornées + Swatch Conflict Conflit de nuances + Swatch Conflicts Conflits de nuances + Swatch Libraries Bibliothèques de nuances + Swatch Libraries Menu Menu Bibliothèques de nuances + Swatch Library Bibliothèque de nuances + Swatch Name: Nom de la nuance : + Swatch Options Options de nuance + Swatch Options… Options de nuance… + Swatch Tiles Carreaux de nuances + Swatches Nuancier + Switch workspace Changer d’espace de travail + Symbol Instance Occurrence de symbole + Symbol Libraries Bibliothèques de symboles + Symbol Libraries (on the roadmap) Bibliothèques de symboles (prévu dans la feuille de route) + Symbol Screener Tool Outil Transparence de symbole + Symbol Scruncher Tool Outil Contraction de symbole + Symbol Shifter Tool Outil Déplacement de symbole + Symbol Sizer Tool Outil Redimensionnement de symbole + Symbol Spinner Tool Outil Rotation de symbole + Symbol Sprayer Pulvérisation de symboles + Symbol Sprayer Tool Outil Pulvérisation de symboles + Symbol Stainer Tool Outil Coloration de symbole + Symbol Styler Tool Outil Style de symbole + Symbolism Tool Outil Symbolisme + Symbols Symboles + System Layout Disposition système + Tab Tabulation + Tab Stops Taquets de tabulation + Tabs Tabulations + Tabular Lining Chiffres tabulaires alignés + Tabular Oldstyle Chiffres tabulaires elzéviriens + Target Cible + Target Contents Contenu de la cible + Target: Cible : + Tate-chu-yoko Tate-chu-yoko + Temperature Température + Temperature/Tint… Température/teinte… + Template Modèle + Templates Modèles + Templates Folder Dossier des modèles + Templates… Modèles… + Text Displayed in Cell: Texte affiché dans la cellule : + Text Export Options Options d’exportation du texte + Text Fill Color Couleur de fond du texte + Text Import Options Options d’importation du texte + Text Stroke Color Couleur de contour du texte + Text Wrap Habillage + Text Wrap Options Options d’habillage + Text Wrap Options… Options d’habillage… + The bounds of the visible art. Les limites de l’illustration visible. + The cell shows the type object's text as HTML. La cellule affiche le texte de l’objet texte au format HTML. + The document has no colour swatches. Le document ne contient aucune nuance de couleur. + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Le bord du papier que l’imprimante ne peut pas atteindre : l’illustration reste à l’intérieur, et l’aperçu l’affiche en pointillés. + The page as it prints La page telle qu’elle s’imprime + the preset le paramètre prédéfini + The Print Tiling tool placed the pages. L’outil Mosaïque d’impression a placé les pages. + The selected objects have no CSS of their own (see below). Les objets sélectionnés n’ont pas de CSS propre (voir ci-dessous). + The system's settings of this printer Les réglages système de cette imprimante + Thin Space Espace fine + Third Space Espace d’un tiers + Third Vanishing Point: Troisième point de fuite : + This document already has a swatch named “{name}” with another color. Ce document contient déjà une nuance nommée « {name} » d’une autre couleur. + This library is empty. Cette bibliothèque est vide. + This panel is on the roadmap (see the parity plan). Ce panneau est prévu dans la feuille de route (voir le plan de parité). + Threaded Text Texte lié + Threads Liens + Three Point Trois points + Three Point Perspective Perspective à trois points + Three Point Perspective Preset 1 Paramètre prédéfini de perspective à trois points 1 + Three Point Perspective Preset 2 Paramètre prédéfini de perspective à trois points 2 + Three Point Perspective Preset 3 Paramètre prédéfini de perspective à trois points 3 + Three Point Perspective Preset 4 Paramètre prédéfini de perspective à trois points 4 + Three Point Perspective Preset 5 Paramètre prédéfini de perspective à trois points 5 + Threshold Seuil + Thumbnail View Vignettes + Thumbnails: Vignettes : + Tile Mosaïque + Tile Edge Color Couleur du bord du carreau + Tile Edge Color… Couleur du bord du carreau… + Tile Full Pages Mosaïque de pages entières + Tile Range: Plage de carreaux : + Tile Type: Type de carreau : + tile {number} carreau {number} + Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. + Tint Teinte + Tip extends past the end point La pointe dépasse le point d’extrémité + Tip on the end point Pointe sur le point d’extrémité + Title Case Majuscule à chaque mot + to à + Toggle Fill/Stroke Focus Basculer entre fond et contour + Toggle Selection Inverser la sélection de l’élément + Toggle single/double column Basculer entre une et deux colonnes + Tolerance Tolérance + Tolerance: Tolérance : + Tool Option Option de l’outil + Tool Options… Options de l’outil… + Toolbar: Advanced / Basic Barre d’outils : avancée / de base + Toolbar: Single/Double Column Barre d’outils : une/deux colonnes + Toolbars Barres d’outils + Tools Outils + Top Haut + Top in Front Haut au premier plan + Top: Haut : + Total Width: Largeur totale : + Touch Type Tool Outil Retouche de texte + Trace Vectoriser + Trace the image with the Default preset Vectoriser l’image avec le paramètre prédéfini Par défaut + Tracking Approche + Trademark Symbol Symbole de marque + Transform Transformation + Transform Again Répéter la transformation + Transform Both Transformer les deux + Transform Each Transformer individuellement + Transform Each… Transformer individuellement… + Transform in Perspective Transformer en perspective + Transform Object Only Transformer l’objet uniquement + Transform Panel Panneau Transformation + Transform Pattern Only Transformer le motif uniquement + Transform Pattern Tiles Transformer les carreaux de motif + Transform Patterns Transformer les motifs + Transform Tools Outils de transformation + Transforms Transformations + Transform… Transformer… + Transparency Transparence + Transparency Flattener Presets Paramètres prédéfinis d’aplatissement des transparences + Transparency Flattener Presets… Paramètres prédéfinis d’aplatissement des transparences… + Transparency Grid Grille de transparence + Transparency Info Informations sur la transparence + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. La transparence est aplatie selon le paramètre prédéfini. Le texte est écrit sous forme de contours vectorisés ; les dégradés, sous forme de bandes de couleur. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. La transparence est aplatie selon le paramètre prédéfini. Le texte est écrit sous forme de contours vectorisés ; les dégradés restent continus. + Transparency Options Options de transparence + Transparent Transparent + Transparent (Default) Transparent (par défaut) + Transparent Background Arrière-plan transparent + Transverse (the page a quarter turn on the paper) Transversal (la page tournée d’un quart de tour sur le papier) + Trap… Recouvrement… + Trim Détourage + Trim mark weight: Épaisseur des traits de coupe : + Trim marks Traits de coupe + Trim View Vue rognée + True colour needs version 2004 or later: the nearest of 256 colours is written. Les couleurs vraies nécessitent la version 2004 ou ultérieure : la plus proche des 256 couleurs est écrite. + Turn off Data Recovery for complex documents Désactiver la récupération des données pour les documents complexes + Tweak… Retoucher… + Twirl Rate: Vitesse de tourbillon : + Twirl Tool Outil Tourbillon + Twist… Torsion… + Two Point Deux points + Two Point Perspective Perspective à deux points + Two Point Perspective Preset 1 Paramètre prédéfini de perspective à deux points 1 + Two Point Perspective Preset 2 Paramètre prédéfini de perspective à deux points 2 + Two Point Perspective Preset 3 Paramètre prédéfini de perspective à deux points 3 + Two Point Perspective Preset 4 Paramètre prédéfini de perspective à deux points 4 + Two Point Perspective Preset 5 Paramètre prédéfini de perspective à deux points 5 + Type Texte + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Le texte est vectorisé, et les contours qu’une ligne CAO ne peut pas tracer deviennent des formes pleines. + Type Object Selection by Path Only Sélection des objets texte par le tracé uniquement + Type on a Path Texte curviligne + Type on a Path Options Options de texte curviligne + Type on a Path Options… Options de texte curviligne… + Type on a Path Tool Outil Texte curviligne + Type Orientation Orientation du texte + Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets. + Type Tool Outil Texte + Type: Type : + Type… Texte… + UI Brightness Luminosité de l’interface + UI Scaling Mise à l’échelle de l’interface + Underline Souligné + Undo Annuler + Undo {name} Annuler {name} + Undo {} Annuler {} + Unembed Annuler l’incorporation + Unembedded to {} Incorporation annulée vers {} + Unembed… Annuler l’incorporation… + Ungroup Dissocier + Ungroup Color Group Dissocier le groupe de couleurs + Unicode Unicode + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Uniform Uniforme + Unique Unique + Unite Réunion + Unite and Simplify Réunir et simplifier + Units Unités + Units: Unités : + Units: {units} Unités : {units} + Unlink harmony colors Dissocier les couleurs de l’harmonie + Unlink Opacity Mask Délier le masque d’opacité + Unlink the mask Délier le masque + Unlock Déverrouiller + Unlock All Tout déverrouiller + Unlock All Layers Déverrouiller tous les calques + Unlock Grid Déverrouiller la grille + Unlock Guides Déverrouiller les repères + Unnamed Objects Objets sans nom + Unprintable margin: Marge non imprimable : + Unshift Annuler le décalage + Untitled Sans titre + Update Link Mettre à jour le lien + Update Links Mettre à jour les liens + Update Modified Links Mettre à jour les liens modifiés + Updated {} image(s) {} image(s) mise(s) à jour + UPPERCASE MAJUSCULES + URL: URL : + Use Utilisez + Use Artboards Utiliser les plans de travail + Use Black Point Compensation Utiliser la compensation du point noir + Use Compression Utiliser la compression + Use document bleed settings Utiliser les paramètres de fond perdu du document + Use Even-Odd Fill Rule Utiliser la règle de remplissage pair-impair + Use Japanese Crop Marks Utiliser les traits de coupe japonais + Use Low Resolution Proxy for Linked EPS Utiliser une image d’aperçu basse résolution pour les EPS liés + Use Non-Zero Winding Fill Rule Utiliser la règle de remplissage non nul + Use Precise Cursors Utiliser des pointeurs précis + Use Pressure Pen Utiliser la pression du stylet + Use Preview Bounds Utiliser les limites d’aperçu + Use Registration Point for Symbol Utiliser le point d’alignement pour le symbole + Use Square for Previews Utiliser un carré pour les aperçus + Use Text for Previews Utiliser du texte pour les aperçus + Use the document's swatch: pasted colors take its color. Utiliser la nuance du document : les couleurs collées prennent sa couleur. + Use the Selected Text's Font Utiliser la police du texte sélectionné + Use Typographer's Quotes Utiliser les guillemets typographiques + User Defined Défini par l’utilisateur + User Defined Libraries Bibliothèques définies par l’utilisateur + User Graphic Style Library 1 Bibliothèque de styles graphiques utilisateur 1 + User Graphic Style Library 10 Bibliothèque de styles graphiques utilisateur 10 + User Graphic Style Library 2 Bibliothèque de styles graphiques utilisateur 2 + User Graphic Style Library 3 Bibliothèque de styles graphiques utilisateur 3 + User Graphic Style Library 4 Bibliothèque de styles graphiques utilisateur 4 + User Graphic Style Library 5 Bibliothèque de styles graphiques utilisateur 5 + User Graphic Style Library 6 Bibliothèque de styles graphiques utilisateur 6 + User Graphic Style Library 7 Bibliothèque de styles graphiques utilisateur 7 + User Graphic Style Library 8 Bibliothèque de styles graphiques utilisateur 8 + User Graphic Style Library 9 Bibliothèque de styles graphiques utilisateur 9 + User Interface Interface utilisateur + User Library Bibliothèque utilisateur + User Preset Paramètre prédéfini utilisateur + User Swatch Library 1 Bibliothèque de nuances utilisateur 1 + User Swatch Library 10 Bibliothèque de nuances utilisateur 10 + User Swatch Library 2 Bibliothèque de nuances utilisateur 2 + User Swatch Library 3 Bibliothèque de nuances utilisateur 3 + User Swatch Library 4 Bibliothèque de nuances utilisateur 4 + User Swatch Library 5 Bibliothèque de nuances utilisateur 5 + User Swatch Library 6 Bibliothèque de nuances utilisateur 6 + User Swatch Library 7 Bibliothèque de nuances utilisateur 7 + User Swatch Library 8 Bibliothèque de nuances utilisateur 8 + User Swatch Library 9 Bibliothèque de nuances utilisateur 9 + V Spacing: Espacement V : + V: V : + Variable Width Profile Profil de largeur variable + Variables Variables + Variation: Variation : + Vector Effects Effets vectoriels + Vector Halftone Trame de demi-teintes vectorielle + Vector Halftone… Trame de demi-teintes vectorielle… + Vector illustration — fast, open, scriptable. Illustration vectorielle — rapide, ouverte, scriptable. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft ne s’est pas fermé normalement la dernière fois. Ces documents comportaient des modifications non enregistrées : + VectorCraft Help… Aide de VectorCraft… + VectorCraft on getartcraft.com VectorCraft sur getartcraft.com + VectorCraft on GitHub VectorCraft sur GitHub + Vectors Vecteurs + Version {version} — open-source vector illustration in pure Rust. Version {version} — illustration vectorielle open source en pur Rust. + Version: Version : + Vertical Vertical + Vertical % % vertical + Vertical Align Bottom Alignement vertical en bas + Vertical Align Center Alignement vertical au centre + Vertical Align Top Alignement vertical en haut + Vertical Area Type Tool Outil Texte captif vertical + Vertical Distribute Bottom Répartition verticale en bas + Vertical Distribute Center Répartition verticale au centre + Vertical Distribute Space Répartition verticale de l’espacement + Vertical Distribute Top Répartition verticale en haut + Vertical Scale % % d’échelle verticale + Vertical Type on a Path Tool Outil Texte curviligne vertical + Vertical Type Tool Outil Texte vertical + Vertical: Vertical : + Very High Très élevée + View Affichage + View Artboard Only Afficher le plan de travail uniquement + View Opacity Mask Afficher le masque d’opacité + View PDF after saving Afficher le PDF après l’enregistrement + View {n} Vue {n} + Viewing Angle: Angle de vue : + Viewing Distance: Distance de vue : + W: L : + Warnings Avertissements + Warp Déformation + Warp Options Options de déformation + Warp Tool Outil Déformation + Wave… Vague… + Web 1920×1080 Web 1920×1080 + Web Safe 216 216 couleurs Web + Web Safe RGB RVB Web + Web Shift Passer aux couleurs Web + Web Snap: Magnétisme Web : + Weight Épaisseur + Weight: Épaisseur : + Welcome to VectorCraft Bienvenue dans VectorCraft + When pasting text Lors du collage de texte + White Blanc + Widget Position: Position du widget : + Width Largeur + Width and Height Largeur et hauteur + Width Point Point de largeur + Width Point Edit Modification du point de largeur + Width Point Edit… Modification du point de largeur… + Width Profile Profil de largeur + Width Profiles Profils de largeur + Width Tool Outil Largeur + Width: Largeur : + Window Fenêtre + Windows Windows + Working CMYK CMJN de travail + Working CMYK (don't tag) CMJN de travail (sans balise) + Workspace Espace de travail + Workspace {i} Espace de travail {i} + Wrinkle Tool Outil Fronce + Write Layers Écrire les calques + XML ID ID XML + Yards Yards + Yellow Jaune + Your browser downloads the files (several as one .zip). Votre navigateur télécharge les fichiers (plusieurs dans un seul .zip). + Your changes will be lost if you don't save them. Vos modifications seront perdues si vous ne les enregistrez pas. + Zig Zag… Zigzag… + Zoom In Zoom avant + Zoom Out Zoom arrière + Zoom to Selection Zoom sur la sélection + Zoom Tool Outil Zoom + Zoom with Mouse Wheel Zoom avec la molette de la souris + 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) + {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. + {effect} is already applied {effect} est déjà appliqué + {format} Options Options {format} + {kind} Gradient Dégradé {kind} + {kind} Style Options… Options de style {kind}… + {kind}, {model} {kind}, {model} + {name} (spot) {name} (ton direct) + {pages} in all, {count} per copy {pages} au total, {count} par exemplaire + {page} of {count} {page} sur {count} + {percent}% opacity {percent} % d’opacité + {secs} sec {secs} s + {shortcut} with the tool sizes the brush {shortcut} avec l’outil règle la taille du pinceau + {title} (selection) {title} (sélection) + {tool} Options Options de {tool} + {tool} Tool Options Options de l’outil {tool} + {warning}: click to correct to the closest color ({hex}) {warning} : cliquez pour corriger avec la couleur la plus proche ({hex}) + “{file}” is protected by a password. « {file} » est protégé par un mot de passe. + ● Recording ● Enregistrement +@plural {n} item|{n} items {n} élément|{n} éléments +@plural Color Table ({n} color)|Color Table ({n} colors) Table des couleurs ({n} couleur)|Table des couleurs ({n} couleurs) +@plural Delete this {n} graphic style?|Delete these {n} graphic styles? Supprimer {n} style graphique ?|Supprimer ces {n} styles graphiques ? +@plural {n} Layer|{n} Layers {n} calque|{n} calques +@plural {n} Object|{n} Objects {n} objet|{n} objets +@plural {n} area highlighted|{n} areas highlighted {n} zone mise en évidence|{n} zones mises en évidence +@plural {n} color|{n} colors {n} couleur|{n} couleurs +@plural {n} fill|{n} fills {n} fond|{n} fonds +@plural {n} glyph|{n} glyphs {n} glyphe|{n} glyphes +@plural {n} object highlighted|{n} objects highlighted {n} objet mis en évidence|{n} objets mis en évidence +@plural {n} star|{n} stars {n} étoile|{n} étoiles +@plural {n} step|{n} steps {n} étape|{n} étapes +@plural {n} stroke|{n} strokes {n} contour|{n} contours + the object to select | sur l’objet pour le sélectionner | + to select multiple objects | pour sélectionner plusieurs objets | + the object to duplicate l’objet pour le dupliquer + an anchor or path segment to select it | sur un point d’ancrage ou un segment de tracé pour le sélectionner | + to move | pour déplacer | + to add pour ajouter + to add a corner point | pour ajouter un point d’angle | + to add a smooth point | pour ajouter un point d’inflexion | + to close | pour fermer | + to finish pour terminer + to add smooth points | pour ajouter des points d’inflexion | + to toggle corner | pour basculer en point d’angle | + to create point type | pour créer du texte de point | + to create area type | pour créer du texte captif | + to exit editing pour quitter la modification + to draw | pour dessiner | + to constrain proportions | pour conserver les proportions | + from center | depuis le centre | + for exact size pour une taille exacte + to set the reference point | pour définir le point de référence | + to transform | pour transformer | + for exact values pour des valeurs exactes + to pan the view pour faire défiler la vue + to zoom in | pour effectuer un zoom avant | + to zoom out | pour effectuer un zoom arrière | + to zoom into an area pour zoomer sur une zone + an object to copy its attributes | sur un objet pour copier ses attributs | + to apply the selection's to it | pour lui appliquer ceux de la sélection | + to sample a color pour prélever une couleur + across a selected object to set the gradient direction sur un objet sélectionné pour définir la direction du dégradé + to select an artboard | pour sélectionner un plan de travail | + on the canvas to create one sur la zone de travail pour en créer un + a stroke to add a width point | sur un contour pour ajouter un point de largeur | + to change one side | pour modifier un seul côté | + to select more points | pour sélectionner d’autres points | + a point to edit it | sur un point pour le modifier | + to remove points pour supprimer des points + the art to add a pin | sur l’illustration pour ajouter une épingle | + to select more pins | pour sélectionner d’autres épingles | + a pin to warp | une épingle pour déformer | + near a selected pin to rotate | près d’une épingle sélectionnée pour faire pivoter | + to remove pins pour supprimer des épingles + across paths to push them along | sur les tracés pour les pousser | + to size the brush ( pour régler la taille du pinceau ( + keeps its proportions) conserve ses proportions) + over paths, and hold still to keep going | sur les tracés, et restez immobile pour continuer | + over paths to roughen their outlines | sur les tracés pour rendre leurs contours irréguliers | + to move in perspective | pour déplacer en perspective | + to copy | pour copier | + while dragging to move perpendicular to the plane | pendant le glissement pour déplacer perpendiculairement au plan | + to scale in perspective pour mettre à l’échelle en perspective + an object, then another to blend them | sur un objet, puis sur un autre pour créer un dégradé de formes | + to blend from it | pour créer un dégradé de formes à partir de celui-ci | + to set spacing and orientation pour définir l’espacement et l’orientation + :  : + Isolate Selected Group Isoler le groupe sélectionné + Release Clipping Mask Annuler le masque d’écrêtage + Make Compound Path Créer un tracé transparent + Release Compound Path Annuler le tracé transparent + — substituted by {style} — remplacé par {style} + — {n} characters from another font — {n} caractères d’une autre police + [Normal Character Style] [Style de caractère normal] + [Normal Paragraph Style] [Style de paragraphe normal] + +# Leading model (Paragraph panel menu, East Asian options). + Top-to-Top Leading Interlignage de haut en haut + Bottom-to-Bottom Leading Interlignage de bas en bas + Burasagari Burasagari + Force Forcer + +# About › Contributors / Models (credits.rs) + About À propos + Contributors Contributeurs + Models Modèles + Username Nom d’utilisateur + Display name Nom affiché + Real name Nom réel + Sort Trier + Reverse the order Inverser l’ordre + Grab bag En vrac + Table Tableau + Name (A–Z) Nom (A–Z) + Merged PRs PR fusionnées + Commits Commits + Lines added Lignes ajoutées + Lines deleted Lignes supprimées + Line delta Écart de lignes + Binary assets added Ressources binaires ajoutées + Binary assets removed Ressources binaires supprimées + First commit Premier commit + Last commit Dernier commit + First Premier + Last Dernier + {people} contributors · {commits} commits {people} contributeurs · {commits} commits + No contributor data was built into this copy. Cette copie ne contient aucune donnée sur les contributeurs. + No model credits were built into this copy. Cette copie ne contient aucun crédit de modèle. + Company Entreprise + Version Version + % of all commits % de tous les commits + Lines +/− Lignes +/− + Joins Sommets + Miter En pointe + Bevel Biseauté + Offset Décalage + Axis Axe +axis Horizontal Horizontal +axis Vertical Vertical +axis Both Les deux + Drag to move Faire glisser pour déplacer + Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante + Put back in the toolbar Remettre dans la barre d’outils + Float Panel Rendre le panneau flottant + Dock Panel Ancrer le panneau + Drag to float the panel group Faire glisser pour rendre le groupe de panneaux flottant + Put back in the dock Remettre dans la zone d’ancrage + Drag to float the toolbar Faire glisser pour rendre la barre d’outils flottante + Drag to the window's left edge to dock Faire glisser vers le bord gauche de la fenêtre pour ancrer + Float Tool Group Rendre le groupe d’outils flottant + +# 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. +@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 +@msg Blend colors: select at least three objects with filled colours Fusion des couleurs : sélectionnez au moins trois objets dotés d’un fond de couleur +@msg Combine Slices: the slices have no bounds Combiner les tranches : les tranches n’ont pas de limites +@msg Convert to Artboards: selection has no area Convertir en plans de travail : la sélection n’a pas de surface +@msg Convert to Shape: no rectangles or ellipses selected Convertir en forme : aucun rectangle ni aucune ellipse sélectionné +@msg Copied the CSS of {rules} object(s) CSS de {rules} objet(s) copié +@msg Corners edits the corners of a path Angles modifie les angles d’un tracé +@msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} +@msg Couldn't open {name}: {e} Impossible d’ouvrir {name} : {e} +@msg Couldn't place {name}: the document it was dropped on was closed Impossible d’importer {name} : le document sur lequel il a été déposé a été fermé +@msg Couldn't read {name}: {e} Impossible de lire {name} : {e} +@msg Couldn't save recovery data: {e} Impossible d’enregistrer les données de récupération : {e} +@msg Create Outlines: select text objects Vectoriser : sélectionnez des objets texte +@msg Create from Guides: the document has no artboard Créer d’après les repères : le document n’a pas de plan de travail +@msg Create from Guides: the guides make more than {MAX_PIECES} slices Créer d’après les repères : les repères forment plus de {MAX_PIECES} tranches +@msg Create from Selection: the selection has no area Créer d’après la sélection : la sélection n’a pas de surface +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) La récupération des données n’a aucun emplacement où conserver les copies : définissez Préférences → Gestion des fichiers → Récupération des données → Dossier (recoveryFolder) +@msg direct-select anchor points to cut at (not the end points of open paths) sélectionnez avec l’outil Sélection directe les points d’ancrage où couper (pas les extrémités des tracés ouverts) +@msg Divide Objects Below: select a path Diviser les objets inférieurs : sélectionnez un tracé +@msg Divide Objects Below: select exactly one path Diviser les objets inférieurs : sélectionnez exactement un tracé +@msg Downloaded {_1} file(s) {_1} fichier(s) téléchargé(s) +@msg Downloaded {what} {what} téléchargé +@msg Expand Appearance: the selection has a basic appearance Décomposer l’aspect : la sélection a un aspect de base +@msg Expand: no brush strokes selected Décomposer : aucun contour de forme sélectionné +@msg Expand: no gradient fills Décomposer : aucun fond en dégradé +@msg Expand: nothing to expand Décomposer : rien à décomposer +@msg Exported {count} file(s) to {folder} {count} fichier(s) exporté(s) vers {folder} +@msg Exported {path} {path} exporté +@msg Flatten Transparency: select objects to flatten Aplatir la transparence : sélectionnez les objets à aplatir +@msg Flatten Transparency: {e} Aplatir la transparence : {e} +@msg Image Trace: the traced image is missing Vectorisation de l’image : l’image vectorisée est manquante +@msg Imported {what}: {_1} {what} importé : {_1} +@msg Installed plug-in {_1} Module externe {_1} installé +@msg Internal error: {msg} (please report this bug) Erreur interne : {msg} (merci de signaler ce bogue) +@msg Live Paint Merge: select a Live Paint group and the paths to add Fusion de peinture dynamique : sélectionnez un groupe de peinture dynamique et les tracés à ajouter +@msg Live Paint: select paths or compound paths Peinture dynamique : sélectionnez des tracés ou des tracés transparents +@msg Live Paint: these paths are too degenerate to paint Peinture dynamique : ces tracés sont trop dégénérés pour être peints +@msg Make Guides: select paths Créer des repères : sélectionnez des tracés +@msg No saved selections (Select → Save Selection…) Aucune sélection enregistrée (Sélection → Enregistrer la sélection…) +@msg No saved views (View → New View…) Aucune vue enregistrée (Affichage → Nouvelle vue…) +@msg Offset Path: select paths Décalage : sélectionnez des tracés +@msg Opened with {_1} note(s): {_2} Ouvert avec {_1} remarque(s) : {_2} +@msg Opened {url} {url} ouvert +@msg Outline Stroke: no stroked paths selected Vectoriser le contour : aucun tracé avec contour sélectionné +@msg PDF settings are not an object Les paramètres PDF ne sont pas un objet +@msg PDF writer error: {0} Erreur du générateur PDF : {0} +@msg Packaged {n} file(s) in {folder}{note} {n} fichier(s) assemblé(s) dans {folder}{note} +@msg Packaged {n} file(s){note} {n} fichier(s) assemblé(s){note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Aperçu dans le navigateur nécessite l’application de bureau (la version web télécharge les fichiers à la place) +@msg Release Guides: no guides Libérer les repères : aucun repère +@msg Release: no layer to put the rectangle in Libérer : aucun calque où placer le rectangle +@msg Replace Spine: select a blend and a path Remplacer la trajectoire : sélectionnez un dégradé de formes et un tracé +@msg select two or more objects to distribute sélectionnez au moins deux objets à répartir +@msg select paths or anchor points sélectionnez des tracés ou des points d’ancrage +@msg SVG parse error: {0} Erreur d’analyse SVG : {0} +@msg Save for Web: the image is empty Enregistrer pour le Web : l’image est vide +@msg Save writes {_1}, not {_2}: use document.export for other formats Enregistrer écrit du {_1}, pas du {_2} : utilisez document.export pour les autres formats +@msg Saved recovery data for {title} Données de récupération enregistrées pour {title} +@msg Saved to User Defined: {_1} Enregistré dans Défini par l’utilisateur : {_1} +@msg Saved {path} {path} enregistré +@msg Select text or click into text with the Type tool to insert glyphs Sélectionnez du texte ou cliquez dans un texte avec l’outil Texte pour insérer des glyphes +@msg Shape Builder: no region under the pointer Concepteur de formes : aucune zone sous le pointeur +@msg Shape Builder: select paths or compound paths Concepteur de formes : sélectionnez des tracés ou des tracés transparents +@msg Show All Hidden Attributes: nothing is hidden Afficher tous les attributs masqués : rien n’est masqué +@msg Split Into Grid: select paths large enough for the grid Diviser en grille : sélectionnez des tracés assez grands pour la grille +@msg Targa files are RGB: colorModel rgb Les fichiers Targa sont en RVB : colorModel rgb +@msg Text Wrap: select a wrap object Habillage de texte : sélectionnez un objet d’habillage +@msg `aspect` must be a number (%) `aspect` doit être un nombre (%) +@msg `at` must be [x, y] `at` doit être [x, y] +@msg `color` must be a colour `color` doit être une couleur +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` doit être une commande dont l’élément de menu ouvre une boîte de dialogue (voir ui.menuDialog) +@msg `freeform.points` must be an array `freeform.points` doit être un tableau +@msg `gradient` must be an object `gradient` doit être un objet +@msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list}) +@msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 +@msg `stops` must be an array `stops` doit être un tableau +@msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) +@msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement +@msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` n’est pas une couleur (#rrggbb ou un nom de paramètre prédéfini) +@msg `{id}` has no envelope dialog `{id}` n’a pas de boîte de dialogue d’enveloppe +@msg `{keep}` is not a side of this axis `{keep}` n’est pas un côté de cet axe +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` doit être `auto` ou un code de langue comme `en` ou `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` doit être une couleur #rrggbb +@msg `{key}` must be a number `{key}` doit être un nombre +@msg `{key}` must be a string `{key}` doit être une chaîne +@msg `{key}` must be a whole number `{key}` doit être un nombre entier +@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` doit être un nombre entier compris entre {min} et {max} (reçu : {x}) +@msg `{key}` must be between {min} and {max} (got {x}) `{key}` doit être compris entre {min} et {max} (reçu : {x}) +@msg `{key}` must be one of {_1} `{key}` doit valoir l’une de ces valeurs : {_1} +@msg `{key}` must be true or false `{key}` doit valoir true ou false +@msg `{k}` must be [x, y] `{k}` doit être [x, y] +@msg `{name}` has no options `{name}` n’a pas d’options +@msg `{name}` is a built-in workspace; choose another name `{name}` est un espace de travail intégré ; choisissez un autre nom +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` est une couleur quadrichromique : les teintes s’appliquent aux couleurs globales et aux tons directs +@msg `{name}` is no recovery entry name `{name}` n’est pas le nom d’une entrée de récupération +@msg `{name}` isn't a colour swatch `{name}` n’est pas une nuance de couleur +@msg `{path}` must be a bool or an object of bools `{path}` doit être un booléen ou un objet de booléens +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` n’est pas une couleur (#rrggbb ou le nom d’un paramètre prédéfini) +@msg `{tool}` isn't a Liquify tool `{tool}` n’est pas un outil Fluidité +@msg `{tool}` isn't a freehand tool `{tool}` n’est pas un outil de dessin à main levée +@msg `{to}` is not an available name `{to}` n’est pas un nom disponible +@msg a PostScript error ({name} in `{op}`) une erreur PostScript ({name} dans `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} une erreur PostScript ({name} dans `{op}`), dans {procs} +@msg a PostScript error ({name} in `{op}`: {at}) une erreur PostScript ({name} dans `{op}` : {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} une erreur PostScript ({name} dans `{op}` : {at}), dans {procs} +@msg a PostScript error ({name}) une erreur PostScript ({name}) +@msg a bitmap is empty une image bitmap est vide +@msg a bitmap is too large to read une image bitmap est trop grande pour être lue +@msg a colour index is 0 to {_1}, not {n} un indice de couleur va de 0 à {_1}, pas {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} une couleur est un indice, #rrggbb ou le nom d’un paramètre prédéfini, pas {v} +@msg a document needs at least one artboard un document doit avoir au moins un plan de travail +@msg a document needs at least one layer un document doit avoir au moins un calque +@msg a freeform gradient keeps at least one point un dégradé de forme libre garde au moins un point +@msg a gradient needs at least two stops un dégradé doit avoir au moins deux points de couleur +@msg a graphic style named `{to}` already exists un style graphique nommé `{to}` existe déjà +@msg a page is missing une page est manquante +@msg a page out of range une page hors limites +@msg a page tree kid isn't a reference un enfant de l’arborescence des pages n’est pas une référence +@msg a page tree node has no kids un nœud de l’arborescence des pages n’a pas d’enfants +@msg a page's placement is not finite la position d’une page n’est pas finie +@msg a preset named `{_1}` exists un paramètre prédéfini nommé `{_1}` existe déjà +@msg a slice size must be a positive number of points la taille d’une tranche doit être un nombre de points positif +@msg a stream runs past the end un flux dépasse la fin du fichier +@msg a stream's length is wrong la longueur d’un flux est incorrecte +@msg a stream's length isn't a number la longueur d’un flux n’est pas un nombre +@msg add a format to export ajoutez un format d’exportation +@msg already editing a pattern un motif est déjà en cours de modification +@msg an object can't be read un objet ne peut pas être lu +@msg an object in perspective would leave the edited grid un objet en perspective sortirait de la grille modifiée +@msg antiAlias `{id}`: none, art or type antiAlias `{id}` : none, art ou type +@msg artboard {0} does not exist le plan de travail {0} n’existe pas +@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 à cette échelle, le dessin mesure {w} × {h} pt, plus que ne peut contenir la zone de travail : choisissez une échelle plus petite ou ajustez-le au plan de travail +@msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800" +@msg bad color {v} couleur non valide {v} +@msg bad colour `{hex}` (#rrggbb) couleur non valide `{hex}` (#rrggbb) +@msg bad page tree arborescence des pages non valide +@msg bad shortcut raccourci non valide +@msg bad stream length longueur de flux non valide +@msg bad trailer trailer non valide +@msg balance must be between 0 and 100 l’équilibre doit être compris entre 0 et 100 +@msg batch step {_1} (`{id}`) failed: {e} l’étape {_1} du lot (`{id}`) a échoué : {e} +@msg brightness must be dark|mediumDark|mediumLight|light la luminosité doit valoir dark|mediumDark|mediumLight|light +@msg can't encode the document: {0} impossible d’encoder le document : {0} +@msg can't parse shortcut `{c}` impossible d’interpréter le raccourci `{c}` +@msg can't parse shortcut `{s}` for `{k}` impossible d’interpréter le raccourci `{s}` pour `{k}` +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} impossible d’enregistrer la version {0} : VectorCraft écrit les versions {MIN_VERSION} à {VERSION} +@msg can't write .{ext} images impossible d’écrire des images .{ext} +@msg cannot distort a zero-size bounding box impossible de déformer un cadre de sélection de taille nulle +@msg cannot read PDF: {0} impossible de lire le PDF : {0} +@msg cannot split an open path at its end point impossible de scinder un tracé ouvert à son extrémité +@msg check an asset to export cochez une ressource à exporter +@msg choice must be "merge" or "add", not `{choice}` choice doit valoir "merge" ou "add", pas `{choice}` +@msg choose a direction to divide in choisissez une direction de division +@msg choose a folder to export to choisissez un dossier d’exportation +@msg choose a location for the package choisissez un emplacement pour l’assemblage +@msg clipboard is empty le presse-papiers est vide +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what} : `{h}` n’est pas une couleur comme "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what} : {MAX_TABLE} couleurs au maximum +@msg command `{0}` is not available right now: {1} la commande `{0}` n’est pas disponible pour le moment : {1} +@msg compound paths can only contain paths les tracés transparents ne peuvent contenir que des tracés +@msg compressed (RLE) bitmaps can't be read yet les images bitmap compressées (RLE) ne peuvent pas encore être lues +@msg damaged endommagé +@msg decimals must be {_1}–{_2} (got {_3}) le nombre de décimales doit être compris entre {_1} et {_2} (reçu : {_3}) +@msg divide into 1 to {MAX_PIECES} slices divisez en 1 à {MAX_PIECES} tranches +@msg effect {k} is `{_1}`, not `{id}` l’effet {k} est `{_1}`, pas `{id}` +@msg empty brush stroke tracé de forme vide +@msg empty cutter objet de découpe vide +@msg empty path tracé vide +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la version de fichier {0} est plus récente que celles que prend en charge cette version de VectorCraft ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide +@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux) +@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits +@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur +@msg guides are locked les repères sont verrouillés +@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée +@msg image data `{_1}` is missing les données d’image `{_1}` sont manquantes +@msg image would be too large; lower the resolution l’image serait trop grande ; réduisez la résolution +@msg index must be a number or first, previous, next or last index doit être un nombre ou first, previous, next ou last +@msg index must be a number or first, previous, next or last, not `{other}` index doit être un nombre ou first, previous, next ou last, pas `{other}` +@msg installing from a path is not available on the web; pass the module as `dataBase64` l’installation depuis un chemin n’est pas disponible sur le web ; transmettez le module sous forme de `dataBase64` +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) erreur interne dans `{cmd}` : {msg} (le document est resté tel qu’il était ; veuillez signaler ce bogue) +@msg internal error: {msg} (please report this bug) erreur interne : {msg} (veuillez signaler ce bogue) +@msg invalid PDF setting: {0} réglage PDF non valide : {0} +@msg invalid image data for `{0}` données d’image non valides pour `{0}` +@msg invalid parameters for `{cmd}`: {msg} paramètres non valides pour `{cmd}` : {msg} +@msg is not an image VectorCraft reads n’est pas une image que VectorCraft sait lire +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know il utilise `{at}`, que le lecteur PostScript de VectorCraft ne connaît pas +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} il utilise `{at}`, que le lecteur PostScript de VectorCraft ne connaît pas, dans {procs} +@msg its Windows metafile preview was opened instead, which may simplify the art son aperçu en métafichier Windows a été ouvert à la place, ce qui peut simplifier l’illustration +@msg its preview was placed instead, at screen resolution son aperçu a été importé à la place, en résolution d’écran +@msg join needs open paths la jonction nécessite des tracés ouverts +@msg lang must be one of {_1} lang doit valoir l’une de ces valeurs : {_1} +@msg layers can't be wrap objects les calques ne peuvent pas être des objets d’habillage +@msg missing `folder` `folder` manquant +@msg missing `id` (see plugin.list) `id` manquant (voir plugin.list) +@msg missing `name` `name` manquant +@msg missing `tool` `tool` manquant +@msg missing index index manquant +@msg multiple windows land with M11.5 les fenêtres multiples arrivent avec M11.5 +@msg name the file nommez le fichier +@msg no CSS to copy: select objects with CSS of their own aucun CSS à copier : sélectionnez des objets ayant leur propre CSS +@msg no Live Paint edge near that point aucun bord de peinture dynamique près de ce point +@msg no Live Paint face at that point aucune face de peinture dynamique à ce point +@msg no PNG encoder aucun encodeur PNG +@msg no active document aucun document actif +@msg no active perspective plane aucun plan de perspective actif +@msg no anchor points selected aucun point d’ancrage sélectionné +@msg no app to open files here aucune application pour ouvrir des fichiers ici +@msg no artboard aucun plan de travail +@msg no artboard {i} (0-based; the document has {_1}) aucun plan de travail {i} (numérotation à partir de 0 ; le document en a {_1}) +@msg no artboard {i} (0-based; the document has {count}) aucun plan de travail {i} (numérotation à partir de 0 ; le document en a {count}) +@msg no artboards named aucun plan de travail indiqué +@msg no artwork aucune illustration +@msg no blend options aucune option de dégradé de formes +@msg no bounds aucune limite +@msg no brush named `{name}` aucune forme nommée `{name}` +@msg no catalog aucun catalogue +@msg no changes since the last save aucune modification depuis le dernier enregistrement +@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité +@msg no colour group `{g}` aucun groupe de couleurs `{g}` +@msg no cross-reference table aucune table de références croisées +@msg no current layer aucun calque actif +@msg no data aucune donnée +@msg no dialog open aucune boîte de dialogue ouverte +@msg no document aucun document +@msg no document open aucun document ouvert +@msg no effect applied yet aucun effet appliqué pour l’instant +@msg no effect at index {k} aucun effet à l’indice {k} +@msg no end record aucun enregistrement de fin +@msg no file manager here aucun gestionnaire de fichiers ici +@msg no file reader aucun lecteur de fichiers +@msg no file system here aucun système de fichiers ici +@msg no file writer aucun module d’écriture de fichiers +@msg no files to place aucun fichier à importer +@msg no format to export to aucun format d’exportation +@msg no free recovery area aucune zone de récupération libre +@msg no gradient stop or freeform point is selected aucun point de couleur ni point de forme libre n’est sélectionné +@msg no graphic style `{name}` aucun style graphique `{name}` +@msg no graphic style `{n}` aucun style graphique `{n}` +@msg no interaction in progress aucune interaction en cours +@msg no key object aucun objet clé +@msg no library `{key}` (see {list}) aucune bibliothèque `{key}` (voir {list}) +@msg no native format aucun format natif +@msg no pages aucune page +@msg no pattern `{name}` aucun motif `{name}` +@msg no perspective grid preset named `{name}` aucun paramètre prédéfini de grille de perspective nommé `{name}` +@msg no plug-in {0} is installed aucun plug-in {0} n’est installé +@msg no plug-in {id} is installed aucun plug-in {id} n’est installé +@msg no point {i} (the gradient has {_1}) aucun point {i} (le dégradé en a {_1}) +@msg no preset editor aucun éditeur de paramètres prédéfinis +@msg no previous transform aucune transformation précédente +@msg no print preset named `{base}` aucun paramètre prédéfini d’impression nommé `{base}` +@msg no print preset named `{name}` aucun paramètre prédéfini d’impression nommé `{name}` +@msg no print service aucun service d’impression +@msg no revert command aucune commande Version précédente +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) aucun paramètre prédéfini PDF enregistré nommé `{name}` (les paramètres prédéfinis intégrés sont en lecture seule : créez-en un nouveau à partir d’eux) +@msg no saved presets aucun paramètre prédéfini enregistré +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) aucun paramètre prédéfini d’impression enregistré nommé `{name}` ({DEFAULT_PRESET} est protégé : créez-en un nouveau à partir de lui) +@msg no saved profiles aucun profil enregistré +@msg no saved selections aucune sélection enregistrée +@msg no saved themes aucun thème enregistré +@msg no segment {segment} on line {line} aucun segment {segment} sur la ligne {line} +@msg no selected slice in the image: select slices, or save all of them aucune tranche sélectionnée dans l’image : sélectionnez des tranches ou enregistrez-les toutes +@msg no selection bounds la sélection n’a pas de limites +@msg no slice selected aucune tranche sélectionnée +@msg no such action action inexistante +@msg no such anchor point d’ancrage inexistant +@msg no such artboard plan de travail inexistant +@msg no such document document inexistant +@msg no such layer or object calque ou objet inexistant +@msg no such object {0} objet {0} inexistant +@msg no such perspective grid preset paramètre prédéfini de grille de perspective inexistant +@msg no such recent file fichier récent inexistant +@msg no such recent font police récente inexistante +@msg no such saved selection sélection enregistrée inexistante +@msg no such segment segment inexistant +@msg no such subpath sous-tracé inexistant +@msg no such user library bibliothèque utilisateur inexistante +@msg no such view vue inexistante +@msg no such width point point de largeur inexistant +@msg no swatch `{name}` aucune nuance `{name}` +@msg no swatch library `{l}` (see swatch.library.list) aucune bibliothèque de nuances `{l}` (voir swatch.library.list) +@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 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 +@msg not a VectorCraft file: {0} ce n’est pas un fichier VectorCraft : {0} +@msg not a VectorCraft shortcut set (missing `overrides`) ce n’est pas un jeu de raccourcis VectorCraft (`overrides` manquant) +@msg not a graphic style library (format `{_1}`) ce n’est pas une bibliothèque de styles graphiques (format `{_1}`) +@msg not a stored central record ce n’est pas un enregistrement central stocké +@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} +@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF +@msg not found introuvable +@msg not in pattern editing mode pas en mode d’édition de motif +@msg nothing encoded rien n’a été encodé +@msg nothing selected aucune sélection +@msg nothing to align rien à aligner +@msg nothing to distort rien à déformer +@msg nothing to export: the document has no visible art (or turn on Use Artboards) rien à exporter : le document n’a aucune illustration visible (ou activez Utiliser les plans de travail) +@msg nothing to preview rien à prévisualiser +@msg nothing to print rien à imprimer +@msg nothing to redo rien à rétablir +@msg nothing to save: the document has no visible art rien à enregistrer : le document n’a aucune illustration visible +@msg nothing to undo rien à annuler +@msg nothing to warp rien à déformer +@msg nothing to wrap rien à envelopper +@msg object {0} cannot have children l’objet {0} ne peut pas avoir d’enfants +@msg object {id} is not a path l’objet {id} n’est pas un tracé +@msg page size {w} × {h} format de page {w} × {h} +@msg page {0} does not exist (the PDF has {1}) la page {0} n’existe pas (le PDF en a {1}) +@msg paste unavailable collage indisponible +@msg path is already closed le tracé est déjà fermé +@msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides +@msg paths: give at least one file paths : indiquez au moins un fichier +@msg pattern missing motif manquant +@msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen +@msg percent must be above 0 and at most 6400, not {p} le pourcentage doit être supérieur à 0 et au plus égal à 6400, pas {p} +@msg pick a colour, gradient or pattern to save as a swatch choisissez une couleur, un dégradé ou un motif à enregistrer comme nuance +@msg plane must be left, right or ground plane doit valoir left, right ou ground +@msg plug-in ABI: {0} ABI du plug-in : {0} +@msg plug-in exceeded its {0} le plug-in a dépassé sa limite : {0} +@msg plug-in failed: {0} échec du plug-in : {0} +@msg plug-in manifest: {0} manifeste du plug-in : {0} +@msg plug-in output: {0} sortie du plug-in : {0} +@msg plug-in parameters: {0} paramètres du plug-in : {0} +@msg plug-in trapped: {0} le plug-in s’est arrêté sur une erreur : {0} +@msg print settings are not an object les réglages d’impression ne sont pas un objet +@msg printing isn't available here l’impression n’est pas disponible ici +@msg range `{s}` is empty la plage `{s}` est vide +@msg range `{s}`: `{part}` names no number plage `{s}` : `{part}` n’indique aucun nombre +@msg range `{s}`: `{part}` runs backwards plage `{s}` : `{part}` est dans l’ordre inverse +@msg range `{s}`: `{t}` is not a number from 1 to {count} plage `{s}` : `{t}` n’est pas un nombre compris entre 1 et {count} +@msg recovery copy `{file}`: {e} copie de récupération `{file}` : {e} +@msg resolutions must be between 1 and 2400 ppi les résolutions doivent être comprises entre 1 et 2400 ppp +@msg result would exceed the canvas (coordinates out of range) le résultat dépasserait la zone de travail (coordonnées hors limites) +@msg scale must be a number from {_1} to {_2}, not {_3} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {_3} +@msg scale must be a number from {_1} to {_2}, not {s} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {s} +@msg select (or target) objects whose appearance isn't basic sélectionnez (ou ciblez) des objets dont l’aspect n’est pas basique +@msg select a Live Paint group sélectionnez un groupe de peinture dynamique +@msg select a blend sélectionnez un dégradé de formes +@msg select a clip group sélectionnez un groupe d’écrêtage +@msg select a gradient mesh sélectionnez un filet de dégradé +@msg select a linked image: an embedded one has no file sélectionnez une image liée : une image incorporée n’a pas de fichier +@msg select a repeat sélectionnez une répétition +@msg select a stroke with a variable width sélectionnez un contour de largeur variable +@msg select a symbol instance sélectionnez une occurrence de symbole +@msg select an Image Trace object sélectionnez un objet de vectorisation de l’image +@msg could not decode image: {0} impossible de décoder l’image : {0} +@msg image is empty l’image est vide +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) l’image mesure {width} × {height} pixels, plus que n’en accepte la vectorisation de l’image ({max} mégapixels) +@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 cette vectorisation créerait plus de {max} points d’ancrage, plus que le document ne peut afficher de façon fluide : augmentez Bruit, réduisez Tracés ou Couleurs, ou choisissez un paramètre prédéfini moins fidèle +@msg select an envelope sélectionnez une enveloppe +@msg select an image sélectionnez une image +@msg select an object in perspective sélectionnez un objet en perspective +@msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) à recolorier +@msg select at least two objects sélectionnez au moins deux objets +@msg select objects, not layers sélectionnez des objets, pas des calques +@msg select one path, or give its `id` sélectionnez un tracé ou indiquez son `id` +@msg select paths to join sélectionnez les tracés à joindre +@msg select the art to repeat sélectionnez l’illustration à répéter +@msg select the artwork to warp sélectionnez l’illustration à déformer +@msg select the objects to transform in perspective sélectionnez les objets à transformer en perspective +@msg select two or more graphic styles to merge sélectionnez au moins deux styles graphiques à fusionner +@msg select type in perspective sélectionnez du texte en perspective +@msg selection has no bounds la sélection n’a pas de limites +@msg shadings of an unknown type were left out les ombrages de type inconnu ont été ignorés +@msg shadings of more than 4096 patches or triangles were left out les ombrages de plus de 4096 carreaux ou triangles ont été ignorés +@msg shadings whose functions are of an unknown type are filled with their middle colour les ombrages dont les fonctions sont de type inconnu sont remplis de leur couleur médiane +@msg shortcut for `{k}` must be a string le raccourci de `{k}` doit être une chaîne +@msg stop {i} needs `offset` le point de couleur {i} nécessite `offset` +@msg stop {i} needs a valid `color` le point de couleur {i} nécessite une `color` valide +@msg stop {i}: `{k}` must be a number point de couleur {i} : `{k}` doit être un nombre +@msg the EMF header is damaged l’en-tête EMF est endommagé +@msg the PDF has no pages le PDF n’a aucune page +@msg the PDF is no longer loaded le PDF n’est plus chargé +@msg the PDF is password-protected: give its password le PDF est protégé par un mot de passe : indiquez-le +@msg the PDF password is wrong le mot de passe du PDF est incorrect +@msg the Save PDF dialog didn't open la boîte de dialogue Enregistrer au format PDF ne s’est pas ouverte +@msg the WMF header is damaged l’en-tête WMF est endommagé +@msg the art could not be sampled impossible d’échantillonner l’illustration +@msg the art has no bounds l’illustration n’a pas de limites +@msg the art is too light for any dots l’illustration est trop claire pour produire des points +@msg the artboard to fit must be a positive size up to {_1} pt le plan de travail cible doit avoir une taille positive d’au plus {_1} pt +@msg the background worker stopped le processus d’arrière-plan s’est arrêté +@msg the compressed file unpacks to more than {_1} MB le fichier compressé dépasse {_1} Mo une fois décompressé +@msg the document has never been saved le document n’a jamais été enregistré +@msg the document has no artboard le document n’a pas de plan de travail +@msg the document has no artboards le document n’a aucun plan de travail +@msg the document has no patterns le document n’a aucun motif +@msg the document has no slices le document n’a aucune tranche +@msg the document has no symbols le document n’a aucun symbole +@msg the drawing has no layout `{_1}` (it has {_2}) le dessin n’a pas de présentation `{_1}` (il a {_2}) +@msg the drawing is no longer loaded le dessin n’est plus chargé +@msg the drawing is too large to open le dessin est trop grand pour être ouvert +@msg the export region is empty la zone d’exportation est vide +@msg the export region is not finite la zone d’exportation n’est pas finie +@msg the export returned unreadable data l’exportation a renvoyé des données illisibles +@msg the export wrote no file l’exportation n’a écrit aucun fichier +@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 le fichier ne contient qu’une page de substitution, pas son illustration (il a été enregistré sans compatibilité PDF) : enregistrez-le à nouveau avec la compatibilité PDF activée, ou au format PDF ou SVG +@msg the file was read up to an error ({why}): what follows it was left out le fichier a été lu jusqu’à une erreur ({why}) : la suite a été ignorée +@msg the file's preview header points outside the file l’en-tête d’aperçu du fichier pointe en dehors du fichier +@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille +@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage +@msg the layer or group is locked le calque ou le groupe est verrouillé +@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué +@msg the object already has an opacity mask l’objet a déjà un masque d’opacité +@msg the object has no geometry l’objet n’a pas de géométrie +@msg the object has no opacity mask l’objet n’a pas de masque d’opacité +@msg the object has no stroke l’objet n’a pas de contour +@msg the object is beyond the plane's horizon l’objet est au-delà de l’horizon du plan +@msg the object isn't in perspective l’objet n’est pas en perspective +@msg the object isn't on a perspective plane l’objet n’est pas sur un plan de perspective +@msg the object would cross the horizon of the perspective plane l’objet franchirait l’horizon du plan de perspective +@msg the objects would leave the grid les objets sortiraient de la grille +@msg the package came back damaged l’assemblage a été renvoyé endommagé +@msg the pointer is beyond the plane's horizon le pointeur est au-delà de l’horizon du plan +@msg the preview image is empty l’image d’aperçu est vide +@msg the print job has no data la tâche d’impression ne contient aucune donnée +@msg the scale is not a usable number l’échelle n’est pas un nombre utilisable +@msg the selected stroke's profile is already listed as {_1} le profil du contour sélectionné figure déjà sous le nom {_1} +@msg the selection has no URL (or they differ) la sélection n’a pas d’URL (ou les URL diffèrent) +@msg the selection isn't in perspective la sélection n’est pas en perspective +@msg the stroke has no weight le contour n’a pas d’épaisseur +@msg the thumbnail image is malformed la vignette est mal formée +@msg the top object must be a path, compound path or text object to use as a clipping mask l’objet du dessus doit être un tracé, un tracé transparent ou un objet texte pour servir de masque d’écrêtage +@msg the {tool} tool has no options l’outil {tool} n’a pas d’options +@msg there are no objects to release il n’y a aucun objet à libérer +@msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici +@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé +@msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec document.exportForScreens +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier +@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 c’est un fichier PostScript (un .ai enregistré dans un ancien format ou sans compatibilité PDF, ou un fichier EPS), qui ne peut pas encore être ouvert : enregistrez-le au format PDF, .ai compatible PDF ou SVG +@msg this is not a PostScript file ce n’est pas un fichier PostScript +@msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj) +@msg truncated tronqué +@msg unknown command `{0}` commande inconnue `{0}` +@msg unknown effect `{id}` effet inconnu `{id}` +@msg unknown format format inconnu +@msg unknown format `{f}` (Save writes {_1}) format inconnu `{f}` (Enregistrer écrit {_1}) +@msg unknown freeform mode `{m}` (points, lines) mode de forme libre inconnu `{m}` (points, lines) +@msg unknown gradient kind `{k}` (linear, radial, freeform) type de dégradé inconnu `{k}` (linear, radial, freeform) +@msg unknown highlight `{h}` mise en évidence inconnue `{h}` +@msg unknown option `{path}.{k}` option inconnue `{path}.{k}` +@msg unknown overprints `{o}` (preserve, simulate or discard) surimpressions inconnues `{o}` (preserve, simulate ou discard) +@msg unknown panel `{raw}` panneau inconnu `{raw}` +@msg unknown preference `{key}` préférence inconnue `{key}` +@msg unknown preset (one of {_1}) paramètre prédéfini inconnu (valeurs possibles : {_1}) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) paramètre prédéfini inconnu `{name}` (high, medium, low ou un paramètre enregistré : voir flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) paramètre prédéfini inconnu `{name}` (voir flattener.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) paramètre prédéfini inconnu `{name}` (voir webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) côté inconnu `{other}` (left|right|top|bottom) +@msg unknown tool `{tool}` outil inconnu `{tool}` +@msg unknown tool `{t}` outil inconnu `{t}` +@msg unknown tool or command `{key}` outil ou commande inconnu `{key}` +@msg unknown workspace (one of {_1}) espace de travail inconnu (valeurs possibles : {_1}) +@msg unsupported: {0} non pris en charge : {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files les fichiers de version {0} ne peuvent pas être compressés : seules les versions {COMPRESSED_SINCE} et ultérieures ouvrent les fichiers compressés +@msg width and height are at least 1 pixel la largeur et la hauteur valent au moins 1 pixel +@msg workspace name can't be empty le nom de l’espace de travail ne peut pas être vide +@msg {C}: no effect at index {from} {C} : aucun effet à l’indice {from} +@msg {_1} failed: {e} échec de {_1} : {e} +@msg {_1} files can be opened but not written (see document.formats) les fichiers {_1} peuvent être ouverts mais pas écrits (voir document.formats) +@msg {_1} font families available {_1} familles de polices disponibles +@msg {_1} is a live effect (Effect › Plug-ins) {_1} est un effet dynamique (Effet › Plug-ins) +@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2} +@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions +@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé +@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits +@msg {command} didn't create an object {command} n’a créé aucun objet +@msg {label} produced an empty result {label} a produit un résultat vide +@msg {label}: lost track of a shape's style {label} : le style d’une forme a été perdu +@msg {label}: select paths {label} : sélectionnez des tracés +@msg {label}: select paths, compound paths, groups or text {label} : sélectionnez des tracés, des tracés transparents, des groupes ou du texte +@msg {label}… {label}… +@msg {name}: CRC mismatch {name} : CRC non concordant +@msg {name}: no local header {name} : aucun en-tête local +@msg {name}: no such recovery entry {name} : entrée de récupération inexistante +@msg {path} can't be found: relink it first {path} est introuvable : rétablissez d’abord le lien +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path} : aucun système de fichiers ici (transmettez dataBase64 pour ouvrir ; omettez le chemin pour obtenir dataBase64) +@msg {path}: {e} {path} : {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} Impossible d’importer {name} : {e} +@msg Couldn't paste from the clipboard: {e} Impossible de coller depuis le presse-papiers : {e} +@msg Saved {path} with {_1} note(s): {first} {path} enregistré avec {_1} remarque(s) : {first} +@msg Exported {what} with {_1} note(s): {first} {what} exporté avec {_1} remarque(s) : {first} +@msg Exported {what} {what} exporté +@msg Saved {n} files beside {path} {n} fichiers enregistrés à côté de {path} +@msg Saving Enregistrement +@msg Saving {_1} Enregistrement de {_1} +@msg Exporting {_1} Exportation de {_1} +@msg Saving recovery data for {_1} Enregistrement des données de récupération de {_1} +@msg Discarded {_1} recovered document(s) {_1} document(s) récupéré(s) supprimé(s) +@msg Restored {_1} recovered document(s): save them to keep them {_1} document(s) récupéré(s) restauré(s) : enregistrez-les pour les conserver +@msg Embedded {n} image(s); {missing} missing: relink them first {n} image(s) incorporée(s) ; {missing} manquante(s) : rétablissez d’abord leurs liens +@msg Embedded {n} image(s) {n} image(s) incorporée(s) +@msg Relinked {_1} image(s) Lien rétabli pour {_1} image(s) +@msg Relinked {n} image(s) Lien rétabli pour {n} image(s) +@msg Relinked {n} image(s); not in that folder: {_1} Lien rétabli pour {n} image(s) ; absentes de ce dossier : {_1} +@msg Updated {_1} image(s) {_1} image(s) mise(s) à jour +@msg Unembedded to {_1} Incorporation annulée vers {_1} +@msg Reset {_1} {_1} réinitialisé +@msg Sent to {_1} Envoyé à {_1} +@msg {_1} files Fichiers {_1} +@msg The graphics were lost and have been restarted Le contexte graphique a été perdu puis redémarré +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} Le processeur graphique essayé en premier n’a pas pu afficher la fenêtre : VectorCraft a redémarré sur {summary} +@msg Loading VectorCraft… Chargement de VectorCraft… +@msg VectorCraft failed to start: {e} Le démarrage de VectorCraft a échoué : {e} +@msg A browser with WebGPU or WebGL2 is required. Un navigateur prenant en charge WebGPU ou WebGL2 est nécessaire. +@msg The graphics were lost and couldn't be restarted. Le contexte graphique a été perdu et n’a pas pu être redémarré. +@msg Your unsaved changes are kept in this browser: reload the page to get them back. Vos modifications non enregistrées sont conservées dans ce navigateur : rechargez la page pour les récupérer. +@msg Reload the page to go on (changes not saved yet may be lost). Rechargez la page pour continuer (les modifications non encore enregistrées risquent d’être perdues). +@msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire +@msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils +@msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe +@msg x and y must both be numbers x et y doivent tous deux être des nombres + +# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. + Services Services + Hide VectorCraft Masquer VectorCraft + Zoom Zoom + Bring All to Front Tout ramener au premier plan + Settings Réglages + Break Link Rompre le lien + On: one locked object showing the document, updated when it changes. Activé : un objet verrouillé qui affiche le document et se met à jour quand celui-ci change. + Off: an editable copy of its art, embedded in this document. Désactivé : une copie modifiable de son illustration, incorporée dans ce document. + Images: link to the file instead of only embedding it. Images : lier le fichier au lieu de seulement l’incorporer. + VectorCraft documents: one locked object, updated when the file changes. Documents VectorCraft : un objet verrouillé, mis à jour quand le fichier change. + A file dialog is open: choose there first Une boîte de dialogue de fichiers est ouverte : faites d’abord votre choix dans celle-ci + Shaper Shaper + Expand Shaper Group Décomposer le groupe Shaper + Release Shaper Group Libérer le groupe Shaper + Select Shaper Face Sélectionner la face Shaper diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7226e6c9..79e95ee5 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -54,6 +54,11 @@ fn plural_none(_: u64) -> usize { 0 } +/// French: 0 and 1 are `one` (« 0 calque »), everything else `other`. +fn plural_french(n: u64) -> usize { + usize::from(n > 1) +} + /// Czech: 1 is `one`, 2–4 `few`, everything else (0, 5…) `other`. fn plural_czech(n: u64) -> usize { match n { @@ -76,7 +81,7 @@ fn plural_russian(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 9] = [ +pub static LANGUAGES: [LangInfo; 10] = [ 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`). @@ -103,6 +108,9 @@ pub static LANGUAGES: [LangInfo; 9] = [ complete_menus: true, catalog: OnceLock::new(), }, + // French: the whole interface and the status and error messages, keeping the same names in + // English as Spanish; every `fr-*` locale (`fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` …) resolves here. + LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, // Russian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here. LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 972990dd..afdcf2f3 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -8,7 +8,7 @@ fn tags_map_to_languages() { assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN)); assert_eq!(lang_from_tag("C"), Some(Lang::EN)); assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN)); - assert_eq!(lang_from_tag("fr_FR"), None); + assert_eq!(lang_from_tag("de_DE"), None); assert_eq!(lang_from_tag("ja_JP.UTF-8"), Lang::from_code("ja")); assert_eq!(lang_from_tag("ja"), Lang::from_code("ja")); // Spanish: every region (and Latin America as a whole) resolves to the one catalog. @@ -19,6 +19,10 @@ fn tags_map_to_languages() { for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] { assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}"); } + // French: France, Belgium, Canada, Switzerland and the rest share the one catalog. + for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] { + assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}"); + } // Traditional Chinese: by region, by script, and with a region after the script. assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH())); assert_eq!(lang_from_tag("zh-TW"), Some(ZH())); @@ -48,7 +52,8 @@ fn candidates_walk_from_specific_to_general() { #[test] fn os_language_lists_are_parsed() { assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH())); - assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Some(Lang::EN)); + assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(Lang::EN)); + assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr")); assert_eq!(first_supported("("), None); // Windows: `reg query HKCU\Control Panel\International /v LocaleName`. let reg = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n LocaleName REG_SZ zh-TW\r\n\r\n"; @@ -418,7 +423,7 @@ fn cs() -> Lang { } /// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English. -const KEEPS_MENU_NAMES: [&str; 6] = ["cs", "es", "it", "ja", "pt-br", "ru"]; +const KEEPS_MENU_NAMES: [&str; 7] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru"]; /// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace /// names and the perspective grid presets (names, shown untranslated wherever else they appear). @@ -555,6 +560,10 @@ fn it() -> Lang { Lang::from_code("it").expect("it registered") } +fn fr() -> Lang { + Lang::from_code("fr").expect("fr registered") +} + fn ru() -> Lang { Lang::from_code("ru").expect("ru registered") } @@ -601,6 +610,27 @@ fn italian_reads_as_italian() { assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); } +/// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0 +/// calque »), and reads the same in the menus and in the panels. +#[test] +fn french_reads_as_french() { + for (en, want) in [ + ("Artboard Tool", "Outil Plan de travail"), + ("Swatches", "Nuancier"), + ("Pathfinder", "Pathfinder"), + ("Stroke", "Contour"), + ("Fill", "Fond"), + ("Direct Selection Tool", "Outil Sélection directe"), + ("Save As…", "Enregistrer sous…"), + ("Undo", "Annuler"), + ] { + assert_eq!(tr(fr(), en), want); + } + assert_eq!(trn(fr(), 0, "{n} Layer", "{n} Layers"), "0 calque"); + assert_eq!(trn(fr(), 1, "{n} Layer", "{n} Layers"), "1 calque"); + assert_eq!(trn(fr(), 2, "{n} Layer", "{n} Layers"), "2 calques"); +} + /// Russian uses the vector-illustration vocabulary its users know, has the three plural forms /// (one/few/many), and reads the same in the menus and in the panels. #[test] @@ -649,11 +679,11 @@ fn russian_plurals_have_three_forms() { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]); } -/// Czech, Spanish and Italian letters (and the punctuation their text uses) come from each family's own first font, not +/// Czech, Spanish, Italian and French letters (and the punctuation their text uses) come from each family's own first font, not /// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the /// face that draws missing glyphs, the first one, as missing.) #[test] -fn czech_spanish_and_italian_glyphs_are_available_without_system_fonts() { +fn latin_script_glyphs_are_available_without_system_fonts() { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); @@ -664,7 +694,7 @@ fn czech_spanish_and_italian_glyphs_are_available_without_system_fonts() { let first = first.unwrap(); let mut font = fonts.fonts.font(&family); let chars = font.characters(); - for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’".chars() + for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆ\u{a0}".chars() { assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}"); } @@ -764,7 +794,7 @@ fn complete_languages_translate_every_message() { } /// Languages whose catalogs cover every status and error message. -const COMPLETE_MESSAGES: &[&str] = &["es", "it", "ru"]; +const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru"]; /// 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 1cedaaea..117e9c4f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -136,7 +136,8 @@ complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-* so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), Italian (`it`, complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*` -locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese +locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), French (`fr`, complete; `fr-FR`, `fr-BE`, +`fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian Portuguese (`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added. - `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a @@ -164,10 +165,10 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every m 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, Italian and Russian cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, Italian, Russian and French 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`, - an `it.tsv` and a `ru.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui + an `it.tsv`, a `ru.tsv` and a `fr.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 e22d22a4ce5c2aaeb2f57e0ceef9712daf3765ef Mon Sep 17 00:00:00 2001 From: Simon Leblanc Date: Fri, 9 Oct 2026 01:10:15 +0200 Subject: [PATCH 55/74] Order the French code and docs alphabetically by language code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit French now sits between Spanish and Italian (es, fr, it, ru): the plural rule, the LANGUAGES registry entry (so the Language menu lists Français before Italiano), the locale tests, the fr() helper and french_reads_as_french. README, ROADMAP and docs/development.md list the languages in the same order. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- ROADMAP.md | 4 +-- crates/ui-egui/src/i18n/mod.rs | 16 ++++----- crates/ui-egui/src/i18n/tests.rs | 60 ++++++++++++++++---------------- docs/development.md | 10 +++--- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 984a7bf0..2e4a43c3 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ milestones, and honest time-to-parity estimates. "a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface -speaks English, Japanese, Traditional and Simplified Chinese, Spanish, Italian, Russian and French (and Czech and Brazilian Portuguese in the menus). +speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus). The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. diff --git a/ROADMAP.md b/ROADMAP.md index 5defa292..e27b9167 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, Italian, Russian and French ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`. - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). @@ -141,7 +141,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). -- **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), Italian (every `it-*` locale), Russian (every `ru-*` locale) and French (every `fr-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, Italian, Russian and French 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, Italian, French and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian 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 and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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 or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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. - **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 79e95ee5..6d19361d 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -54,11 +54,6 @@ fn plural_none(_: u64) -> usize { 0 } -/// French: 0 and 1 are `one` (« 0 calque »), everything else `other`. -fn plural_french(n: u64) -> usize { - usize::from(n > 1) -} - /// Czech: 1 is `one`, 2–4 `few`, everything else (0, 5…) `other`. fn plural_czech(n: u64) -> usize { match n { @@ -68,6 +63,11 @@ fn plural_czech(n: u64) -> usize { } } +/// French: 0 and 1 are `one` (« 0 calque »), everything else `other`. +fn plural_french(n: u64) -> usize { + usize::from(n > 1) +} + /// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`. fn plural_russian(n: u64) -> usize { match n % 100 { @@ -98,6 +98,9 @@ pub static LANGUAGES: [LangInfo; 10] = [ complete_menus: true, catalog: OnceLock::new(), }, + // French: the whole interface and the status and error messages, keeping the same names in + // English as Spanish; every `fr-*` locale (`fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` …) resolves here. + LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, // Italian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `it-*` locale (`it-IT`, `it-CH`, `it-SM` …) resolves here. LangInfo { @@ -108,9 +111,6 @@ pub static LANGUAGES: [LangInfo; 10] = [ complete_menus: true, catalog: OnceLock::new(), }, - // French: the whole interface and the status and error messages, keeping the same names in - // English as Spanish; every `fr-*` locale (`fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` …) resolves here. - LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_french, complete_menus: true, catalog: OnceLock::new() }, // Russian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here. LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index afdcf2f3..0eba9e73 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -15,10 +15,6 @@ fn tags_map_to_languages() { for tag in ["es", "es_ES.UTF-8", "es-MX", "es_AR", "es-419", "es-US"] { assert_eq!(lang_from_tag(tag), Lang::from_code("es"), "{tag}"); } - // Italian: Italy, Switzerland and San Marino share the one catalog. - for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] { - assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}"); - } // French: France, Belgium, Canada, Switzerland and the rest share the one catalog. for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] { assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}"); @@ -547,6 +543,10 @@ fn menu_catalogs_translate_every_menu_label() { } assert_eq!(tr(cs(), "File"), "Soubor"); assert_eq!(tr(Lang::from_code("ja").expect("ja"), "File"), "ファイル"); + // Italian: Italy, Switzerland and San Marino share the one catalog. + for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] { + assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}"); + } assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo"); assert_eq!(tr(es(), "File"), "Archivo"); assert_eq!(tr(it(), "Edit"), "Modifica"); @@ -556,14 +556,14 @@ fn es() -> Lang { Lang::from_code("es").expect("es registered") } -fn it() -> Lang { - Lang::from_code("it").expect("it registered") -} - fn fr() -> Lang { Lang::from_code("fr").expect("fr registered") } +fn it() -> Lang { + Lang::from_code("it").expect("it registered") +} + fn ru() -> Lang { Lang::from_code("ru").expect("ru registered") } @@ -589,27 +589,6 @@ fn spanish_reads_as_spanish() { assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas"); } -/// Italian uses the vector-illustration vocabulary its users know, has two plural forms like -/// English (zero takes the plural), and reads the same in the menus and in the panels. -#[test] -fn italian_reads_as_italian() { - for (en, want) in [ - ("Artboard Tool", "Strumento Tavola da disegno"), - ("Swatches", "Campioni"), - ("Pathfinder", "Elaborazione tracciati"), - ("Stroke", "Traccia"), - ("Fill", "Riempimento"), - ("Direct Selection Tool", "Strumento Selezione diretta"), - ("Save As…", "Salva con nome…"), - ("Undo", "Annulla"), - ] { - assert_eq!(tr(it(), en), want); - } - assert_eq!(trn(it(), 1, "{n} Layer", "{n} Layers"), "1 livello"); - assert_eq!(trn(it(), 0, "{n} Layer", "{n} Layers"), "0 livelli"); - assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); -} - /// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0 /// calque »), and reads the same in the menus and in the panels. #[test] @@ -631,6 +610,27 @@ fn french_reads_as_french() { assert_eq!(trn(fr(), 2, "{n} Layer", "{n} Layers"), "2 calques"); } +/// Italian uses the vector-illustration vocabulary its users know, has two plural forms like +/// English (zero takes the plural), and reads the same in the menus and in the panels. +#[test] +fn italian_reads_as_italian() { + for (en, want) in [ + ("Artboard Tool", "Strumento Tavola da disegno"), + ("Swatches", "Campioni"), + ("Pathfinder", "Elaborazione tracciati"), + ("Stroke", "Traccia"), + ("Fill", "Riempimento"), + ("Direct Selection Tool", "Strumento Selezione diretta"), + ("Save As…", "Salva con nome…"), + ("Undo", "Annulla"), + ] { + assert_eq!(tr(it(), en), want); + } + assert_eq!(trn(it(), 1, "{n} Layer", "{n} Layers"), "1 livello"); + assert_eq!(trn(it(), 0, "{n} Layer", "{n} Layers"), "0 livelli"); + assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); +} + /// Russian uses the vector-illustration vocabulary its users know, has the three plural forms /// (one/few/many), and reads the same in the menus and in the panels. #[test] @@ -679,7 +679,7 @@ fn russian_plurals_have_three_forms() { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]); } -/// Czech, Spanish, Italian and French letters (and the punctuation their text uses) come from each family's own first font, not +/// Czech, Spanish, French and Italian letters (and the punctuation their text uses) come from each family's own first font, not /// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the /// face that draws missing glyphs, the first one, as missing.) #[test] diff --git a/docs/development.md b/docs/development.md index 117e9c4f..a8d00ba7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -134,10 +134,10 @@ Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, c in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Simplified Chinese (`zh-hans`, complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it, so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral -international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), Italian (`it`, +international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`, +complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`, complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*` -locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), French (`fr`, complete; `fr-FR`, `fr-BE`, -`fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian Portuguese +locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese (`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added. - `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a @@ -165,10 +165,10 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), French (`fr`, comple 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, Italian, Russian and French cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian and Russian 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`, - an `it.tsv`, a `ru.tsv` and a `fr.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui + a `fr.tsv`, an `it.tsv` and a `ru.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 c640a04612585c18f0f7e3da83d1d984cad0177e Mon Sep 17 00:00:00 2001 From: Simon Leblanc Date: Fri, 9 Oct 2026 01:10:15 +0200 Subject: [PATCH 56/74] Translate the French strings drawn through translating helpers These labels reach the screen through helpers that translate their argument (dim_label, dropdown, tl!(CONST)), so the tl! literal scan doesn't see them: the Libraries panel's empty state, Recolor Artwork's 1/2/3 Color Job presets, Spot Colors' CMYK Equivalents, the Eyedropper's 3 x 3 / 5 x 5 Average, Print's PDF File destination and Save PDF's CMYK profile in effect output intent. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/fr.tsv | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 74b3434f..91d6f0ce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3605,3 +3605,12 @@ axis Both Les deux Expand Shaper Group Décomposer le groupe Shaper Release Shaper Group Libérer le groupe Shaper Select Shaper Face Sélectionner la face Shaper + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. Faites glisser ici des illustrations, des couleurs et des styles de texte pour les réutiliser d’un document à l’autre. Les bibliothèques sont enregistrées sur cet ordinateur — aucun compte n’est nécessaire. + 1 Color Job Travail 1 couleur + 2 Color Job Travail 2 couleurs + 3 Color Job Travail 3 couleurs + CMYK Equivalents Équivalents CMJN + 3 x 3 Average Moyenne 3 x 3 + 5 x 5 Average Moyenne 5 x 5 + PDF File Fichier PDF + CMYK profile in effect Profil CMJN en vigueur From 0716a0d104233cf8212ef348d607a5dc09f2335a Mon Sep 17 00:00:00 2001 From: Simon Leblanc Date: Fri, 9 Oct 2026 01:14:11 +0200 Subject: [PATCH 57/74] Translate the French labels added on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free Transform's widget (Free Transform, Perspective Distort, Constrain), the Properties panel's artboard editing (Artboard, Exit) and Type › Insert Inline Symbol, found by the catalog completeness tests after the rebase. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/i18n/fr.tsv | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 91d6f0ce..de6a9b2d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3614,3 +3614,9 @@ axis Both Les deux 5 x 5 Average Moyenne 5 x 5 PDF File Fichier PDF CMYK profile in effect Profil CMJN en vigueur + Artboard Plan de travail + Constrain Contraindre + Exit Quitter + Free Transform Transformation manuelle + Perspective Distort Distorsion de perspective + Insert Inline Symbol Insérer un symbole dans le texte From 65f35f20a6a55c7dd2a40ab40c31c03a57849e0a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 06:25:14 +0700 Subject: [PATCH 58/74] French: ICF Top/Right and ICF Bottom/Left (#631 landed beside the catalog) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/fr.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index de6a9b2d..135d3eec 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3620,3 +3620,5 @@ axis Both Les deux Free Transform Transformation manuelle Perspective Distort Distorsion de perspective Insert Inline Symbol Insérer un symbole dans le texte + ICF Top/Right ICF haut/droite + ICF Bottom/Left ICF bas/gauche From 409af66699af09ee3a969b1845c7605076f99c3e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 06:26:49 +0700 Subject: [PATCH 59/74] Tests: #631's ICF test builds its runs with #627's inline field #627 added TextRun::inline while #631 was open. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b044ad48..35339fe4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -658,7 +658,10 @@ fn character_alignment_on_the_icf_lines_small_characters_up_with_the_largest_fac let st = |size: f64| CharStyle { font_family: family.into(), ..style(size) }; let mut t = point("", st(40.0)); t.vertical = vertical_type; - t.runs = vec![TextRun { text: "大".into(), style: st(40.0) }, TextRun { text: "小".into(), style: CharStyle { char_align: a, ..st(20.0) } }]; + t.runs = vec![ + TextRun { text: "大".into(), style: st(40.0), inline: None }, + TextRun { text: "小".into(), style: CharStyle { char_align: a, ..st(20.0) }, inline: None }, + ]; let l = layout(db(), &t); let (big, small) = (l.glyphs[0].origin, l.glyphs[1].origin); if vertical_type { small.x - big.x } else { big.y - small.y } From 53b4845f6f1c3b1448f4d765e81cd3e674c7afb2 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Fri, 9 Oct 2026 08:49:03 +0900 Subject: [PATCH 60/74] =?UTF-8?q?View=20=E2=80=BA=20Pixel=20Preview:=20sho?= =?UTF-8?q?w=20the=20document=20pixels=20the=20art=20rasterizes=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel Preview only switched the pixel grid on from 600%: the art was still rendered for the screen and drawn with linear filtering, so curves and strokes stayed smooth vectors. Now, while a document pixel is bigger than a screen pixel, the canvas renders the visible document pixels one per point (anti-aliased by coverage), uploads them with nearest filtering and places them over the document rect they cover, turning with a rotated view. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 124 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 3 + 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 88cd0850..8469d5c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,7 +136,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size). - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. -- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). - **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). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cac25c34..19eb2ad6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -218,6 +218,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Artwork raster. let ppp = ui.ctx().pixels_per_point(); let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); + // View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel + // is bigger than a screen pixel (below that the screen render already shows it). + let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let key = CacheKey { doc: st.uid as usize, revision: st.revision, @@ -233,6 +236,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { rot: v.rotation, anti_alias: app.session.prefs.anti_aliased_artwork, placed: vectorcraft_render::placed_document::generation(), + pixel, }; // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { @@ -248,16 +252,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) && done.key.doc == key.doc { - upload(app, ui.ctx(), &done.img); + upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; } if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { - let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) - * Affine::rotate(v.rotation.to_radians()) - * Affine::scale(v.zoom * ppp as f64) - * Affine::translate(-v.center.to_vec2()); + let (w, h, view) = match pixel { + Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), + None => ( + w, + h, + Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) + * Affine::rotate(v.rotation.to_radians()) + * Affine::scale(v.zoom * ppp as f64) + * Affine::translate(-v.center.to_vec2()), + ), + }; let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; let opts = vectorcraft_render::RenderOptions { proof: vectorcraft_render::proof::active_proof(), @@ -284,7 +295,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { _ => { let t0 = now_ms(); let img = app.canvas.renderer.render(&doc, w, h, view, &opts); - upload(app, ui.ctx(), &img); + upload(app, ui.ctx(), &img, pixel.is_some()); app.canvas.key = Some(key.clone()); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; @@ -293,14 +304,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) && k.doc == key.doc - && (k.rot - v.rotation).abs() < 1e-9 { - // Reproject the last frame if it was rendered for a different view. - let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot }; - let _ = k.rot; - let a = xf.to_screen(old.to_doc(rect.min)); - let b = xf.to_screen(old.to_doc(rect.max)); - painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + match k.pixel { + // Document pixels: placed over the document rect they cover, turning with the view. + Some([x0, y0, x1, y1]) => { + let q = xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)); + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (pos, uv) in q.into_iter().zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(Shape::mesh(mesh)); + } + None if (k.rot - v.rotation).abs() < 1e-9 => { + // Reproject the last frame if it was rendered for a different view. + let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot }; + let a = xf.to_screen(old.to_doc(rect.min)); + let b = xf.to_screen(old.to_doc(rect.max)); + painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => {} + } } if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); @@ -1717,14 +1742,31 @@ fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); } -fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered) { +/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. +fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; match &mut app.canvas.texture { - Some(tex) => tex.set(color, egui::TextureOptions::LINEAR), - None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)), + Some(tex) => tex.set(color, options), + None => app.canvas.texture = Some(ctx.load_texture("canvas", color, options)), } } +/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view +/// included. None when the view is degenerate. +fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { + let r = xf.rect; + let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p)); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in corners { + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil()); + // A document pixel is bigger than a screen pixel here, so the region is about canvas-sized. + let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0; + ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64]) +} + /// The Contextual Task Bar's area. fn task_bar_id() -> egui::Id { egui::Id::new("task-bar") @@ -2249,6 +2291,56 @@ mod tests { (v, out.textures_delta) } + /// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one + /// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas + /// renders for the screen again. + #[test] + fn pixel_preview_shows_the_document_pixels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Its 1 pt stroke covers a quarter of document pixel 99 and most of pixel 100. + app.session.execute("shape.rectangle", &json!({"x": 100.25, "y": 50, "width": 200, "height": 200})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + // The art's raster uploaded by one frame: (width, height, hard-edged, pixels). + let upload = |app: &mut VectorcraftApp| { + let (_, mut delta) = frame_output(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| { + let egui::ImageData::Color(img) = &d.image; + (img.width(), img.height(), d.options.magnification == egui::TextureFilter::Nearest, img.pixels.clone()) + }); + delta.clear(); + up.expect("the art re-rendered") + }; + let screen = upload(&mut app); // the first frame fits the view to the artboard + assert!(screen.0 > 600 && !screen.2, "out of Pixel Preview: the screen's pixels, smoothed"); + app.ui.view.pixel_preview = true; + let view = app.view_mut().unwrap(); + (view.zoom, view.center) = (8.0, Point::new(100.0, 150.0)); + let (w, h, hard, px) = upload(&mut app); + assert!(hard, "hard-edged pixels"); + assert!(w <= screen.0 / 8 + 2 && h <= screen.1 / 8 + 2, "one pixel per point: {w} × {h}"); + let [x0, y0, x1, y1] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered"); + assert_eq!(((x1 - x0) as usize, (y1 - y0) as usize), (w, h)); + // Each document pixel is one texel, anti-aliased by how much of it the art covers. + let alpha = |x: i64| px[((150 - y0) * w as i64 + x - x0) as usize].a(); + assert!((40..=90).contains(&alpha(99)), "a quarter of pixel 99: {}", alpha(99)); + assert!(alpha(100) > 200, "most of pixel 100: {}", alpha(100)); + assert_eq!((alpha(98), alpha(101)), (0, 255)); + // The pixels are placed over the document rect they cover. + let s = shapes(&mut app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let Some(Shape::Mesh(mesh)) = s.iter().find(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)) else { panic!("the art") }; + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(x0 as f64, y0 as f64)); + assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos); + // Zoomed out, a document pixel is no bigger than a screen pixel: rendered for the screen. + app.view_mut().unwrap().zoom = 0.5; + let (w, _, hard, _) = upload(&mut app); + assert!(w > 600 && !hard, "zoomed out: the screen's pixels"); + } + /// General › Anti-aliased Artwork (#394): on (the default), the art's edges are smoothed on /// screen; off, every pixel is either painted or not, and turning it either way re-renders. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d5d9da2..a4955ac7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -288,6 +288,9 @@ pub struct CacheKey { pub anti_alias: bool, /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. pub placed: u64, + /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown + /// with hard edges. None: the art is rendered for the screen. + pub pixel: Option<[i64; 4]>, } #[derive(Clone, Copy, Debug, Default)] From d5e6be15656799562fbb6e70f274dbc4dd652f95 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 07:36:21 +0700 Subject: [PATCH 61/74] Part of #372: SVG import keeps wrapped layers and round-trips sublayers Top-level groups already opened as layers named from data-name, else their id. Now that also holds when one more id-less group wraps them (a transform on all the art, or a 's chosen group), and hidden objects inside a are kept (hidden) instead of dropped. Nested groups stay groups unless something marks them as layers: the SVG export now writes data-vc-layer="sublayer" on layers inside layers, and import makes such a group (or an Inkscape layer group) a sublayer of the layer holding it, in that layer's colour. A marked group anywhere else stays a group. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/svg/src/export.rs | 14 ++- crates/svg/src/import.rs | 104 +++++++++++++++--- crates/svg/tests/symbols_layers.rs | 171 ++++++++++++++++++++++++++++- 4 files changed, 270 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8469d5c0..bd12def6 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -158,7 +158,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). - **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); 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). + - 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; 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). - 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). - 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. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8031c3c4..a7a803dd 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -68,6 +68,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: symbol_nest: 0, fonts: Vec::new(), shared_images: HashMap::new(), + layer_nest: 0, }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -266,6 +267,8 @@ struct Writer<'a> { /// Images written once in the defs and drawn with ``, by key and size: the pieces an /// envelope cuts a distorted image into share its pixels. shared_images: HashMap<(String, u32, u32), String>, + /// Layers being written inside one another (more than one: a sublayer). + layer_nest: usize, } /// The characters type uses from one face, under one `@font-face` description: the family the @@ -545,8 +548,8 @@ impl Writer<'_> { } /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to /// differ from it (spaces, punctuation, duplicates, the unique prefix). - /// The id of `n`'s element (with its name when the id isn't it), then the object's own data - /// as `data-*` attributes. + /// The id of `n`'s element (with its name when the id isn't it), a sublayer's mark + /// ([`crate::import::SUBLAYER`]), then the object's own data as `data-*` attributes. fn id_attr(&self, n: &Node) -> String { if self.anonymous { return String::new(); @@ -556,6 +559,10 @@ impl Writer<'_> { (Some(id), _) => format!(" id=\"{}\"", xml_escape(id)), (None, _) => String::new(), }; + // A sublayer is marked as one, so import makes it a sublayer again, not a group. + if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { + out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); + } for (k, v) in n.attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { // Names as XML takes them; ours (`name`, `vc-…`) are written by the export itself. let valid = k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && k != "name" && !k.starts_with("vc-"); @@ -1215,6 +1222,8 @@ impl Writer<'_> { /// An object, inside `` when it links to a URL (Attributes panel). fn node(&mut self, n: &Node) { + let layer = usize::from(n.is_layer()); + self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { Some(url) => { self.line(&format!("", xml_escape(url))); @@ -1225,6 +1234,7 @@ impl Writer<'_> { } None => self.node_body(n), } + self.layer_nest -= layer; } fn node_body(&mut self, n: &Node) { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index e796891d..b3f61767 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use usvg::roxmltree; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, PatternDef, + Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef, StrokeLayer, Unit, }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; @@ -65,6 +65,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec` elements become layers (as in the reference app); otherwise all art goes - // into "Layer 1". A clip path on one (as on an Inkscape layer, or our clipping layers) makes it - // a clipping layer. Top-level text joins the layer below it (the first layer if none is). + // Top-level `` elements become layers (as in the reference app), named from their + // labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on + // one (as on an Inkscape layer, or our clipping layers) makes it a clipping layer. Top-level + // text joins the layer below it (the first layer if none is). let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(g) && im.text_slot(g).is_none()); let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some()); - let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c)); - if layer_mode { + let layer_mode = |im: &Importer, top: &usvg::Group| { + top.children().iter().any(|c| is_layer(im, c)) && top.children().iter().all(|c| is_layer(im, c) || is_text(im, c)) + }; + // The layers may sit in more wrappers (a transform on all the art, a ``'s group). + if !layer_mode(&im, top) { + let (mut inner, mut ts) = (top, base); + while let Some(g) = im.wrapper(inner) { + ts *= aff(g.transform()); + inner = g; + } + if layer_mode(&im, inner) { + (top, base) = (inner, ts); + } + } + if layer_mode(&im, top) { im.doc.layers.clear(); let mut loose = vec![]; for c in top.children() { @@ -136,9 +148,48 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec` as a sublayer rather than a group: our export writes it on the layers +/// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.) +pub(crate) const SUBLAYER: &str = "data-vc-layer"; + +/// The Inkscape namespace (its layers and their labels). +const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape"; + +/// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or +/// the label beside it) is its name. +fn marked_layer(g: XNode) -> bool { + g.tag_name().name() == "g" + && g.attribute("id").is_some_and(|id| !id.is_empty()) + && (g.has_attribute(SUBLAYER) || g.attribute((INKSCAPE, "groupmode")) == Some("layer")) +} + +/// Turn the groups made of elements marked as layers (`marked`, see [`marked_layer`]) right inside +/// layer `l` into its sublayers, in its colour, and theirs in turn. A marked group anywhere else +/// (in a group, a mask, a symbol) stays a group: only layers hold layers. +fn promote_sublayers(l: &mut Node, marked: &HashSet) { + let NodeKind::Layer { color, children, .. } = &mut l.kind else { return }; + let color = *color; + for c in children.iter_mut().filter(|c| marked.contains(&c.id)) { + let c = Arc::make_mut(c); + let NodeKind::Group { children, clip } = &mut c.kind else { continue }; + let mut sub = Node::layer(c.id, "", color); + sub.set_clips(*clip); + if let Some(ch) = sub.children_mut() { + *ch = std::mem::take(children); + } + c.kind = sub.kind; + promote_sublayers(c, marked); + } +} + /// SVG's initial `font-size` (`medium`) in user units. pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0; @@ -214,6 +265,10 @@ struct Importer { non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// Ids of the `` elements marked as layers ([`marked_layer`]). + sublayers: HashSet, + /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). + sublayer_groups: HashSet, /// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling /// stroke's width is measured in. screen: f64, @@ -326,6 +381,8 @@ struct Found { non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// Ids of the `` elements marked as layers ([`marked_layer`]). + sublayers: HashSet, warnings: Vec, } @@ -396,7 +453,7 @@ impl Edits { fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) - if !["(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { } } } + found.sublayers = elements().filter(|g| marked_layer(*g)).filter_map(|g| g.attribute("id")).map(str::to_string).collect(); let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect(); for u in elements().filter(|n| n.tag_name().name() == "use") { if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) { @@ -440,19 +498,20 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { /// The elements that are objects (what [`hidden_objects`] shows). const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"]; -/// Show the undisplayed objects outside `` (and the like) for usvg, recording their ids. +/// Show the undisplayed objects outside `` (and the like) for usvg, recording their ids. The +/// objects in a `` count too (some apps wrap all their layers in one). fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet) { let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); let mut todo: Vec = xml.root_element().children().filter(XNode::is_element).collect(); while let Some(n) = todo.pop() { let tag = n.tag_name().name(); - if !OBJECTS.contains(&tag) || n.attribute("id").is_some_and(|id| linked.contains(id)) { + if n.attribute("id").is_some_and(|id| linked.contains(id)) { continue; } - if matches!(tag, "g" | "a") { + if matches!(tag, "g" | "a" | "switch") { todo.extend(n.children().filter(XNode::is_element)); } - if css.own(n, "display").as_deref() != Some("none") { + if !OBJECTS.contains(&tag) || css.own(n, "display").as_deref() != Some("none") { continue; } ids.insert(edits.id(svg, n)); @@ -498,7 +557,6 @@ fn link(n: &mut Node, url: &str) { /// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or /// `aria-label` (the first one present). fn labels(xml: &roxmltree::Document) -> HashMap { - const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape"; const SERIF: &str = "http://www.serif.com/"; xml.descendants() .filter_map(|n| { @@ -609,6 +667,15 @@ impl Importer { } } + /// The child of `g` when it's all `g` holds and only wraps art: an id-less group that leaves + /// it as it is (but for a transform) and isn't a text. + fn wrapper<'g>(&self, g: &'g usvg::Group) -> Option<&'g usvg::Group> { + match g.children() { + [usvg::Node::Group(w)] if w.id().is_empty() && is_plain(w) && self.text_slot(w).is_none() => Some(w), + _ => None, + } + } + fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec> { g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect() } @@ -853,6 +920,9 @@ impl Importer { n.blend = blend(g.blend_mode()); n.isolate = g.isolate(); n.mask = mask; + if !is_text && self.sublayers.contains(g.id()) { + self.sublayer_groups.insert(n.id); + } Some(n) } diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs index e424c25e..ddce7c18 100644 --- a/crates/svg/tests/symbols_layers.rs +++ b/crates/svg/tests/symbols_layers.rs @@ -1,7 +1,7 @@ //! Symbols export as one `` def and a `` per instance (instances the def can't stand //! for get their own art), hidden layers and objects are kept hidden when asked and come back -//! hidden with their `data-*` attributes, clipped layers come in as clipping layers, and the -//! editing data notices edits made elsewhere. +//! hidden with their `data-*` attributes, clipped layers come in as clipping layers, layers and +//! sublayers keep their names and nesting, and the editing data notices edits made elsewhere. // 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)] @@ -350,6 +350,173 @@ fn hidden_groups_others_use_stay_hidden() { assert_eq!((paths, hidden), (0, 0), "{:?}", d.layers); } +/// The layer tree: each object by kind (layers and groups with their names), `(hidden)` when +/// hidden, its contents indented below it. +fn outline(d: &Document) -> Vec { + fn walk(n: &Node, depth: usize, out: &mut Vec) { + let name = n.children().map(|_| format!(" {}", n.name.as_deref().unwrap_or("-"))).unwrap_or_default(); + let hidden = if n.visible { "" } else { " (hidden)" }; + out.push(format!("{}{}{name}{hidden}", " ".repeat(depth), n.kind_label())); + for c in n.children().into_iter().flatten() { + walk(c, depth + 1, out); + } + } + let mut out = vec![]; + for l in &d.layers { + walk(l, 0, &mut out); + } + out +} + +/// Layers as other apps export them (#372): named top-level groups, one hidden; a hidden named +/// group and a hidden object inside a layer. +const EXPORTED_LAYERS: &str = r##" + + + + + + +"##; + +#[test] +fn top_level_groups_become_named_layers() { + let expected = ["Layer Main", " Path", " Path (hidden)", " Group HiddenSub (hidden)", " Path", "Layer Second (hidden)", " Path"]; + let d = import(EXPORTED_LAYERS).unwrap(); + assert_eq!(outline(&d), expected); + // A nested group stays a group: nothing in the file says it's a sublayer. + // `data-name` names the layer when the id can't (spaces, punctuation). + let named = EXPORTED_LAYERS.replace("", ""); + assert_eq!(import(&named).unwrap().layers[0].name.as_deref(), Some("Main Art")); + // Layers wrapped in more groups: a transform on all the art, or a `` choosing them. + let moved = EXPORTED_LAYERS.replace("", "").replace("", ""); + let d = import(&moved).unwrap(); + assert_eq!(outline(&d), expected, "{moved}"); + let stroke = &d.layers[0].children().unwrap()[0]; + assert_eq!(stroke.geometric_bounds().unwrap(), Rect::new(25.0, 55.0, 145.0, 115.0)); + let switched = EXPORTED_LAYERS + .replace("", "") + .replace("", ""); + assert_eq!(outline(&import(&switched).unwrap()), expected, "{switched}"); + // Art beside the groups puts everything in one layer, as before. + let loose = EXPORTED_LAYERS.replace("", ""); + assert_eq!(import(&loose).unwrap().layers.iter().map(|l| l.name.as_deref()).collect::>(), [Some("Layer 1")]); +} + +/// Layer "Base" holding a path, a named group, a clipping sublayer and a hidden sublayer "Notes" +/// with a sublayer of its own; layer "Top" above it. +fn sublayered() -> Document { + let mut d = Document::new(100.0, 100.0); + let square = |d: &mut Document, x: f64| { + let ap = Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0); + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, x, x + 10.0, x + 10.0)), ap)) + }; + let layer = |children: Vec>, d: &mut Document, name: &str| { + let mut l = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(3)); + *l.children_mut().unwrap() = children; + l + }; + let deep = layer(vec![square(&mut d, 60.0)], &mut d, "Deep"); + let mut notes = layer(vec![square(&mut d, 50.0), Arc::new(deep)], &mut d, "Notes"); + notes.visible = false; + let mut clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 30.0, 30.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut window = layer(vec![Arc::new(clip), square(&mut d, 25.0)], &mut d, "Window"); + window.set_clips(true); + let mut group = Node::group(d.alloc_id(), vec![square(&mut d, 30.0), square(&mut d, 40.0)]); + group.name = Some("Pair".into()); + let art = vec![square(&mut d, 10.0), Arc::new(group), Arc::new(window), Arc::new(notes)]; + let base = Arc::make_mut(&mut d.layers[0]); + base.name = Some("Base".into()); + *base.children_mut().unwrap() = art; + let top = layer(vec![square(&mut d, 80.0)], &mut d, "Top"); + d.layers.push(Arc::new(top)); + d +} + +#[test] +fn sublayers_round_trip() { + let d = sublayered(); + let expected = [ + "Layer Base", + " Path", + " Group Pair", + " Path", + " Path", + " Layer Window", + " Path", + " Path", + " Layer Notes (hidden)", + " Path", + " Layer Deep", + " Path", + "Layer Top", + " Path", + ]; + assert_eq!(outline(&d), expected); + let styles = [vectorcraft_svg::Styling::PresentationAttributes, vectorcraft_svg::Styling::InlineStyle, vectorcraft_svg::Styling::InternalCss]; + for styling in styles { + // Saved with the hidden sublayer: the same tree comes back. + let kept = export(&d, &ExportOptions { hidden_layers: true, styling, ..Default::default() }); + assert_eq!(kept.matches("data-vc-layer").count(), 3, "sublayers only: {kept}"); + let back = import(&kept).unwrap(); + assert_eq!(outline(&back), expected, "{kept}"); + // A sublayer takes its layer's colour. + back.walk(|n| { + if let NodeKind::Layer { color, .. } = &n.kind + && n.name.as_deref() != Some("Top") + { + assert_eq!(*color, vectorcraft_doc::LayerColor::Preset(0), "{:?}", n.name); + } + }); + assert!(back.layers[0].children().unwrap()[2].clips()); + assert_similar(&render_artboard(&back), &render_artboard(&d), 24.0, 0.002); + } + // Exported without hidden art, the visible sublayer still comes back as one. + let plain = import(&export(&d, &ExportOptions::default())).unwrap(); + assert_eq!( + outline(&plain), + ["Layer Base", " Path", " Group Pair", " Path", " Path", " Layer Window", " Path", " Path", "Layer Top", " Path"] + ); +} + +#[test] +fn marked_groups_are_sublayers_only_in_layers() { + // Inkscape marks its layers and sublayers; one marked group in a plain group stays a group. + let svg = r##" + + + + + +"##; + let d = import(svg).unwrap(); + assert_eq!( + outline(&d), + [ + "Layer Base", + " Path", + " Layer Sub (hidden)", + " Path", + " Layer Deeper", + " Path", + " Group g1", + " Path", + " Group layer4", + " Path", + ] + ); + // Art outside layers ("Layer 1" holds it): a marked group there is a sublayer of it. + let loose = svg.replace("", ""); + let d = import(&loose).unwrap(); + assert_eq!(outline(&d)[..4], ["Layer Layer 1", " Layer Base", " Path", " Layer Sub (hidden)"]); +} + #[test] fn editing_data_notices_edits_made_elsewhere() { let d = symbol_doc(2.0, &[Affine::translate((50.0, 50.0))]); From 977e5d1967a872153b93d6155fc273e6b4171620 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 08:19:36 +0700 Subject: [PATCH 62/74] Fix #638: Layers panel lays out only the rows in view and keeps open rows in the file The panel laid out a row (with its thumbnail and several whole-document lookups) for every open row each frame. A layer of 5,000 groups of 10 paths, all open, took about 29 s a frame headless. Now the rows shown are listed once per document change (with their layer colour and dimming worked out on the way down) and ScrollArea::show_rows lays out only those in view: 0.4 ms a frame. A row highlighted out of view is scrolled to. The open rows move from UI state into the document's state (DocState::layers_open, OpenRows): a document opens with only its top-level layers open, and native files save the open rows (Document::layers_open, written only when not the default, at save time like the view, so opening or closing rows never marks the document modified). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/lib.rs | 6 + crates/engine/src/cmd/fileio/save.rs | 20 ++- crates/engine/src/lib.rs | 56 ++++++- crates/engine/src/tests_save.rs | 31 ++++ crates/ui-egui/src/panels/layers.rs | 241 ++++++++++++++++++++------- crates/ui-egui/src/state.rs | 4 - docs/mcp.md | 1 + 8 files changed, 290 insertions(+), 73 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bd12def6..dc268384 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -134,7 +134,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size). -- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. +- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). - **Geometry and effects:** diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bd627930..372a8ae9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -610,6 +610,11 @@ pub struct Document { /// save time only, so changing the view never marks the document modified. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_view: Option, + /// The layers, sublayers and groups open in the Layers panel when the document was saved; they + /// reopen that way. `None`: the default, only the top-level layers open. Written at save time + /// only, like [`Document::last_view`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layers_open: Option>, /// Edit → Assign Profile: the profiles the document is tagged with (files before format v3 /// kept them in `unknown`, see [`Document::migrate_color_profiles`]). #[serde(default, skip_serializing_if = "ColorProfiles::is_empty")] @@ -702,6 +707,7 @@ impl Document { metadata: DocMetadata::default(), raster_effects: RasterEffectsSettings::default(), last_view: None, + layers_open: None, color_profiles: ColorProfiles::default(), export_settings: Default::default(), extra: Default::default(), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 0f9d29e7..b147b40a 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -216,15 +216,20 @@ fn is_svg(f: &Format) -> bool { matches!(f.id, "svg" | "svgz") } -/// The document as written: native files carry the view to reopen at. +/// The document as written: native files carry the view to reopen at and the Layers panel's open +/// rows. fn doc_to_save(st: &DocState, f: &Format) -> Arc { - if is_native(f) && st.doc.last_view != st.view { - let mut d = (*st.doc).clone(); - d.last_view = st.view.clone(); - Arc::new(d) - } else { - st.doc.clone() + if !is_native(f) { + return st.doc.clone(); } + let open = st.layers_open.saved(&st.doc); + if st.doc.last_view == st.view && st.doc.layers_open == open { + return st.doc.clone(); + } + let mut d = (*st.doc).clone(); + d.last_view = st.view.clone(); + d.layers_open = open; + Arc::new(d) } /// What a format loses against a native file (reported whenever a save writes it). @@ -274,6 +279,7 @@ fn artboard_doc(doc: &Document, i: usize) -> Document { d.artboards = vec![a.clone()]; // It opens fitted to its artboard, not at the master file's view. d.last_view = None; + d.layers_open = None; for layer in &mut d.layers { keep_on(layer, r); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15786eb4..bdc9063c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -153,6 +153,8 @@ pub struct DocState { pub recovery: Option, /// View → Show Print Tiling, per document (view state: not saved, not undoable). pub print_tiling: bool, + /// The rows open in the Layers panel (view state: not undoable; native files keep it). + pub layers_open: OpenRows, /// Transform Again after a perspective move or scale (Perspective Selection tool): the /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. pub last_perspective: Option, @@ -160,11 +162,61 @@ pub struct DocState { static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold. +/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only +/// its top-level layers open. +#[derive(Clone, Debug, Default)] +pub struct OpenRows { + ids: std::collections::HashSet, + /// Counts the changes, so views can keep what they work out from the open rows. + generation: u64, +} + +impl OpenRows { + /// The rows `saved` in a file, else `doc`'s top-level layers. + fn new(doc: &Document, saved: Option>) -> Self { + let ids = match saved { + Some(ids) => ids.into_iter().collect(), + None => doc.layers.iter().map(|l| l.id).collect(), + }; + Self { ids, generation: 0 } + } + pub fn contains(&self, id: NodeId) -> bool { + self.ids.contains(&id) + } + /// Open or close row `id`. + pub fn set(&mut self, id: NodeId, open: bool) { + let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) }; + self.generation += u64::from(changed); + } + /// Changes so far: the same number means the same open rows. + pub fn generation(&self) -> u64 { + self.generation + } + /// What a native file keeps of the open rows: those of `doc` that hold others, in id order; + /// `None` when they are the default (only the top-level layers). + pub fn saved(&self, doc: &Document) -> Option> { + let mut ids = vec![]; + doc.walk(|n| { + if n.children().is_some() && self.ids.contains(&n.id) { + ids.push(n.id); + } + }); + ids.sort_unstable(); + let mut layers: Vec = doc.layers.iter().map(|l| l.id).collect(); + layers.sort_unstable(); + (ids != layers).then_some(ids) + } +} + impl DocState { pub fn new(mut doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); - // The saved view lives here while the document is open (saves write it back). + // The saved view and open Layers rows live here while the document is open (saves write + // them back). let view = doc.last_view.take(); + let saved_open = doc.layers_open.take(); + let layers_open = OpenRows::new(&doc, saved_open); let doc = Arc::new(doc); Self { saved_doc: doc.clone(), @@ -190,6 +242,7 @@ impl DocState { recovered: false, recovery: None, print_tiling: false, + layers_open, last_perspective: None, } } @@ -956,6 +1009,7 @@ impl Session { st.save_options = old.save_options.clone(); st.converted = old.converted; st.view = old.view.clone(); + st.layers_open = old.layers_open.clone(); let old = std::mem::replace(&mut self.docs[index], st); if let Some(stash) = &mut self.batch_stash { stash.push(old); diff --git a/crates/engine/src/tests_save.rs b/crates/engine/src/tests_save.rs index 4c61d0e5..72215c24 100644 --- a/crates/engine/src/tests_save.rs +++ b/crates/engine/src/tests_save.rs @@ -260,6 +260,37 @@ fn the_saved_view_round_trips_without_dirtying() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn open_layers_rows_round_trip_without_dirtying() { + let d = dir("open-rows"); + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + // A document starts with its top-level layers open, and a file keeps nothing for that. + assert!(s.doc().unwrap().layers_open.contains(layer) && !s.doc().unwrap().layers_open.contains(g)); + let native = path(&d, "rows.vectorcraft"); + s.execute("document.save", &json!({"path": native})).unwrap(); + let bytes = std::fs::read(&native).unwrap(); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().layers_open, None); + // Opening the group and closing the layer is view state: not an edit, kept by the file. + let st = s.doc_mut().unwrap(); + st.layers_open.set(g, true); + st.layers_open.set(layer, false); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("document.save", &json!({"path": native})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers_open, None, "only the written file carries it"); + let mut again = session(); + again.execute("document.open", &json!({"path": native})).unwrap(); + let open = &again.doc().unwrap().layers_open; + assert!(open.contains(g) && !open.contains(layer)); + // Older files (none saved) open with the top-level layers open. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap(); + assert_eq!(doc.layers_open, None); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn save_plans_suggest_names_and_folders() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index f338e07b..51a30339 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -24,13 +24,20 @@ //! on the trash clears it. //! //! While an opacity mask is edited the panel lists only its art, under an `` entry. +//! +//! The rows open are the document's ([`OpenRows`], saved in native files): a document opens with +//! only its top-level layers open. The rows shown are listed once per document change, and only +//! those scrolled into view are laid out, so a document of many thousand objects costs no more +//! per frame than a small one. use std::collections::HashSet; +use std::sync::Arc; use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_engine::OpenRows; use crate::theme::Tokens; use crate::widgets::{Live, PanelDrag}; @@ -198,9 +205,97 @@ fn opens(n: &Node, opts: &PanelOptions) -> bool { } } -/// Whether `n` or anything in it has a name containing `q` (lowercase). -fn matches(n: &Node, q: &str) -> bool { - n.display_name().to_lowercase().contains(q) || n.children().is_some_and(|c| c.iter().any(|c| matches(c, q))) +/// One row of the panel. +#[derive(Clone)] +struct Row { + node: Arc, + /// Nesting level (0: a top-level layer). + depth: usize, + /// A clipping path: its group or layer clips. + clip_path: bool, + /// It holds rows ([`opens`]), and shows them. + opens: bool, + open: bool, + /// The colour of its layer (its own, for a layer). + colour: Color32, + /// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer. + dim: bool, +} + +/// What the rows were listed from: listed again when any of it changes. +#[derive(Clone, PartialEq)] +struct RowsKey { + uid: u64, + revision: u64, + open: u64, + layers_only: bool, + query: String, +} + +/// The rows the panel shows, top down: the layers (only the opacity mask's while one is edited) +/// with the rows of the open ones inside them. A search shows every row whose name, or the name of +/// anything inside it, contains `query` (lowercase). +fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str) -> Vec { + let mask_layer = doc.mask_edit.map(|m| m.layer); + let mut out = vec![]; + for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out); + } + out +} + +/// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds +/// it (the layer's colour, and whether it is visible). +fn push_rows( + n: &Arc, + depth: usize, + clip_path: bool, + (colour, shown): (Color32, bool), + cx: (&OpenRows, &PanelOptions, &str), + out: &mut Vec, +) { + let (open_rows, opts, query) = cx; + if opts.layers_only && !n.is_layer() { + return; + } + let colour = match &n.kind { + NodeKind::Layer { color, .. } => { + let [r, g, b] = color.rgb(); + Color32::from_rgb(r, g, b) + } + _ => colour, + }; + let shown = shown && n.visible; + let opens = opens(n, opts); + let searching = !query.is_empty(); + let open = opens && (searching || open_rows.contains(n.id)); + let at = out.len(); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() }); + if open && let Some(children) = n.children() { + for (i, c) in children.iter().enumerate().rev() { + push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); + } + } + // Searching, a row stays for its own name or for a row it keeps inside it. + if searching && out.len() == at + 1 && !n.display_name().to_lowercase().contains(query) { + out.truncate(at); + } +} + +/// The rows of the active document as [`list_rows`] lists them, listed again only when the +/// document, its open rows, the search or Show Layers Only changed. +fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions, query: &str) -> Arc> { + let now = + RowsKey { uid: st.uid, revision: st.revision, open: st.layers_open.generation(), layers_only: opts.layers_only, query: query.to_string() }; + let id = key("rows"); + if let Some((was, rows)) = ui.data(|d| d.get_temp::<(RowsKey, Arc>)>(id)) + && was == now + { + return rows; + } + let rows = Arc::new(list_rows(&st.doc, &st.layers_open, opts, query)); + ui.data_mut(|d| d.insert_temp(id, (now, rows.clone()))); + rows } /// Everything one frame of the panel reads. @@ -213,8 +308,6 @@ struct View<'a> { opts: PanelOptions, t: Tokens, h: f32, - /// The search field's text, lowercase (empty: every row). - query: String, column: Option, /// The row to scroll into view. reveal: Option, @@ -224,8 +317,6 @@ struct View<'a> { #[derive(Default)] struct Out { actions: Vec<(String, Value)>, - /// The rows shown, top down. - order: Vec, /// A row clicked with these modifiers (resolved once every row is listed). click: Option<(NodeId, egui::Modifiers)>, /// The column drag after this frame (`Some(None)`: it ended). @@ -249,23 +340,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { d.insert_temp(shown_doc, st.uid); }); } - let uid = st.uid; let doc = st.doc.clone(); let rows = st.highlighted_rows(); // Rows highlighted anew (a click, Locate Object, a new layer): open the layers around them. let seen: Vec = ui.data(|d| d.get_temp(key("seen"))).unwrap_or_default(); let mut reveal = None; - let expanded = app.ui.layers_expanded.entry(uid).or_insert_with(|| doc.layers.iter().map(|l| l.id.0).collect()); if rows != seen { - for r in &rows { - for a in doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default() { - expanded.insert(a.0); + let around: Vec = + rows.iter().flat_map(|r| doc.ancestry(*r).unwrap_or_default().split_last().map(|(_, a)| a.to_vec()).unwrap_or_default()).collect(); + if let Some(st) = app.session.active_mut() { + for a in around { + st.layers_open.set(a, true); } } reveal = rows.first().copied(); ui.data_mut(|d| d.insert_temp(key("seen"), rows.clone())); } let query = crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")).trim().to_lowercase(); + let Some(st) = app.session.active() else { return }; + let list = rows_of_doc(ui, st, &app.ui.layers_panel, &query); let view = View { doc: &doc, sel: st.selection.objects.iter().copied().collect(), @@ -275,30 +368,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { opts: app.ui.layers_panel.clone(), t, h: app.ui.layers_panel.row(), - query, column: ui.data(|d| d.get_temp(key("column"))), reveal, }; - // While an opacity mask is edited only its art is listed. - let mask_layer = doc.mask_edit.map(|m| m.layer); let mut out = Out::default(); ui.add_space(6.0); let list_h = ui.available_height() - 34.0; - let list = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| { + // Only the rows in view are laid out; one revealed out of view is scrolled to. + let revealed = reveal.and_then(|id| list.iter().position(|r| r.node.id == id)); + let scrolled = ui.scope(|ui| { ui.spacing_mut().item_spacing.y = 0.0; - let expanded = app.ui.layers_expanded.entry(uid).or_default(); - for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - row(ui, &view, l, 0, false, expanded, &mut out); - } + egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, view.h, list.len(), |ui, range| { + if let Some(i) = revealed.filter(|i| !range.contains(i)) { + let top = ui.max_rect().top() + (i as f32 - range.start as f32) * view.h; + ui.scroll_to_rect(egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), view.h)), None); + } + for r in list.get(range).unwrap_or_default() { + row(ui, &view, r, &mut out); + } + }) }); - let list_rect = list.inner_rect; - resolve_click(ui, &view, &mut out); - let expanded = app.ui.layers_expanded.entry(uid).or_default(); - for (id, open) in out.toggle.drain(..) { - if open { - expanded.insert(id.0); - } else { - expanded.remove(&id.0); + let list_rect = scrolled.inner.inner_rect; + resolve_click(ui, &view, &list, &mut out); + if let Some(st) = app.session.active_mut() { + for (id, open) in out.toggle.drain(..) { + st.layers_open.set(id, open); } } // The column drag: live while it lasts, one undo step when the button is released. @@ -415,15 +509,16 @@ fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc: &Document /// Resolve the row clicked this frame: plain (highlight it), Ctrl/Cmd (toggle it) or Shift /// (highlight the rows from the last one clicked down to it). -fn resolve_click(ui: &Ui, view: &View, out: &mut Out) { +fn resolve_click(ui: &Ui, view: &View, list: &[Row], out: &mut Out) { let Some((id, m)) = out.click.take() else { return }; let anchor: Option = ui.data(|d| d.get_temp(key("anchor"))); ui.data_mut(|d| d.insert_temp(key("focus"), true)); + let at = |id: NodeId| list.iter().position(|r| r.node.id == id); if m.shift && let Some(a) = anchor.or(view.rows.last().copied()) - && let (Some(i), Some(j)) = (out.order.iter().position(|r| *r == a), out.order.iter().position(|r| *r == id)) + && let (Some(i), Some(j)) = (at(a), at(id)) { - let range: Vec = out.order.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.0).collect(); + let range: Vec = list.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().map(|r| r.node.id.0).collect(); out.actions.push(("layer.highlight".into(), json!({"ids": range, "mode": if m.command { "add" } else { "set" }}))); return; } @@ -447,19 +542,14 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { } } -#[allow(clippy::too_many_arguments)] -fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expanded: &mut HashSet, out: &mut Out) { +/// The widgets of row `item`. +fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + let n = &*item.node; + let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); let doc = view.doc; let t = &view.t; - if !view.query.is_empty() && !matches(n, &view.query) { - return; - } - if view.opts.layers_only && !n.is_layer() { - return; - } let h = view.h; let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - out.order.push(n.id); if view.reveal == Some(n.id) { ui.scroll_to_rect(r, None); } @@ -470,7 +560,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id)); any }); - let color = layer_colour(doc, n.id); // Highlighted rows (else the current layer's). let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) }; if highlighted { @@ -490,9 +579,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand colour_bar(ui, n, r, x, color); x += 6.0 + depth as f32 * INDENT; // Disclosure triangle; Alt opens or closes everything inside too. - let searching = !view.query.is_empty(); - let has_children = opens(n, &view.opts); - let open = has_children && (searching || expanded.contains(&n.id.0)); if has_children { let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click()); @@ -548,8 +634,7 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand } else { painted_name(n, &name, crate::i18n::current()) }; - let dim = !doc.is_visible(n.id) || n.is_template(); - let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if dim { t.text_dim } else { t.text }); + let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text }); name_end = text.right(); // A clipping path's name is underlined, a masked object's with a dashed line. if clip_path { @@ -605,11 +690,6 @@ fn row(ui: &mut Ui, view: &View, n: &Node, depth: usize, clip_path: bool, expand ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } drop_target(ui, view, n, r, x - 16.0, open, &resp, out); - if open && let Some(children) = n.children() { - for (i, c) in children.iter().enumerate().rev() { - row(ui, view, c, depth + 1, i == 0 && n.clips(), expanded, out); - } - } } /// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer @@ -951,15 +1031,9 @@ pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { v } }; - let uid = st.uid; - let layers: Vec = st.doc.layers.iter().map(|l| l.id.0).collect(); - let set = app.ui.layers_expanded.entry(uid).or_insert_with(|| layers.into_iter().collect()); + let st = app.session.active_mut().ok_or("no document open")?; for i in &ids { - if open { - set.insert(*i); - } else { - set.remove(i); - } + st.layers_open.set(NodeId(*i), open); } Ok(json!({ "count": ids.len() })) } @@ -1623,4 +1697,53 @@ mod tests { assert_eq!(rows_of(&app), vec![sub]); assert_eq!(app.session.active().unwrap().current_layer(), Some(sub)); } + + /// A layer of 2,000 groups of 10 paths with every row open: one frame lays out only the rows + /// in view, and a row highlighted far below them is scrolled to. + #[test] + fn only_the_rows_in_view_are_laid_out() { + use vectorcraft_doc::Appearance; + use vectorcraft_geom::{Rect, shapes}; + let mut d = Document::new(1000.0, 1000.0); + let mut groups = vec![]; + let mut needle = None; + for g in 0..2000 { + let kids = (0..10) + .map(|k| { + let r = Rect::new(k as f64, g as f64 * 0.1, k as f64 + 1.0, g as f64 * 0.1 + 1.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + if g == 0 && k == 0 { + n.name = Some("Needle".into()); + needle = Some(n.id); + } + Arc::new(n) + }) + .collect(); + groups.push(Arc::new(Node::group(d.alloc_id(), kids))); + } + *Arc::make_mut(&mut d.layers[0]).children_mut().unwrap() = groups; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.add_document(d, None); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + // (shapes drawn, whether the needle's name is among them); a second passes per frame, so + // scrolling ends. + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp| { + time += 1.0; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 800.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let found = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Needle")); + (out.shapes.len(), found) + }; + let (shapes, found) = frame(&mut app); + // 22,001 rows; about 30 fit. + assert!(shapes < 1000 && !found, "{shapes} shapes"); + let needle = needle.unwrap(); + app.session.execute("layer.highlight", &json!({"ids": [needle.0], "mode": "set"})).unwrap(); + let found = (0..4).any(|_| frame(&mut app).1); + assert_eq!(app.session.active().unwrap().highlighted_rows(), [needle]); + assert!(found, "the highlighted row is scrolled into view"); + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e81b8cd9..b6af00be 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -365,9 +365,6 @@ pub struct UiState { /// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only). #[serde(default)] pub layers_panel: crate::panels::layers::PanelOptions, - /// The Layers panel's open rows, per open document (`DocState::uid` → node ids). - #[serde(skip)] - pub layers_expanded: std::collections::HashMap>, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -521,7 +518,6 @@ impl Default for UiState { dxf_import: Value::Null, home: None, layers_panel: Default::default(), - layers_expanded: Default::default(), window: None, } } diff --git a/docs/mcp.md b/docs/mcp.md index 7e93013c..c1cfd177 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -882,6 +882,7 @@ and `layerRows`, the rows highlighted in the panel. dimImages, dimPercent; OK is one undo step), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: layersOnly, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and `ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside). + A document opens with only its top-level layers open; the open rows are saved in the native file and reopen that way. ```json {"name":"run_command","arguments":{"command":"layer.newSublayer","params":{"name":"Shadows"}}} From a2e4206155935b0eebdbb33841dd7371c757027e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 09:00:22 +0700 Subject: [PATCH 63/74] Part of #372: SVG import names the one layer from the root element Other editors export a document with a single layer without a layer group: the layer's name goes on the root (id, or data-name) and its art sits directly inside. Such a file opened as "Layer 1"; now its one layer takes the root's name. Files whose top-level groups are the layers are unchanged, and an Inkscape root's generated id (with inkscape:version) isn't taken as a name. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/svg/src/import.rs | 22 ++++++++++++++++++---- crates/svg/tests/symbols_layers.rs | 22 +++++++++++++++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 284341e1..779d7c28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,7 +158,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). - **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; 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). + - 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). - 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). - 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. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index b3f61767..d03e29aa 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -33,6 +33,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec`, or its `data-name`), as exports of +/// one-layer documents write it. An Inkscape root's id (`svg8`) is one Inkscape made up, not a name. +fn root_layer_name(root: XNode) -> Option { + let id = root.attribute("id").filter(|_| root.attribute((INKSCAPE, "version")).is_none()); + root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) +} + /// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. fn marked_layer(g: XNode) -> bool { diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/symbols_layers.rs index ddce7c18..259abda3 100644 --- a/crates/svg/tests/symbols_layers.rs +++ b/crates/svg/tests/symbols_layers.rs @@ -1,7 +1,8 @@ //! Symbols export as one `` def and a `` per instance (instances the def can't stand //! for get their own art), hidden layers and objects are kept hidden when asked and come back //! hidden with their `data-*` attributes, clipped layers come in as clipping layers, layers and -//! sublayers keep their names and nesting, and the editing data notices edits made elsewhere. +//! sublayers keep their names and nesting (a one-layer file's from its root element), and the +//! editing data notices edits made elsewhere. // 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)] @@ -482,6 +483,25 @@ fn sublayers_round_trip() { ); } +#[test] +fn a_root_element_names_the_one_layer() { + // A one-layer document exported with its art loose under the root, which carries the name. + let svg = r##" + + +"##; + assert_eq!(outline(&import(svg).unwrap()), ["Layer Main", " Path", " Group HiddenSub (hidden)", " Path"]); + // `data-name` when the id can't hold the name. + let named = svg.replace("id=\"Main\"", "id=\"Main_Art\" data-name=\"Main Art\""); + assert_eq!(import(&named).unwrap().layers[0].name.as_deref(), Some("Main Art")); + let names = |svg: &str| import(svg).unwrap().layers.iter().map(|l| l.name.clone().unwrap()).collect::>(); + // Not when the groups at the top are the layers. + assert_eq!(names(&svg.replace("", "stroke-width:4\"/>")), ["Art", "HiddenSub"]); + // Nor from the id Inkscape gives its root. + let inkscape = svg.replace("id=\"Main\"", "id=\"svg8\" xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" inkscape:version=\"1.3\""); + assert_eq!(names(&inkscape), ["Layer 1"]); +} + #[test] fn marked_groups_are_sublayers_only_in_layers() { // Inkscape marks its layers and sublayers; one marked group in a plain group stays a group. From 762bd6b927e1347ed422034b2c3e26efe9994738 Mon Sep 17 00:00:00 2001 From: mateususa <150644469+mateususa@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:19:59 -0700 Subject: [PATCH 64/74] Artboard tool: Delete and Backspace delete the active artboard The Artboard tool handled Delete/Backspace in Tool::key, but never claimed the keys through Tool::claims_key. The UI only routes Delete/Backspace to a tool that claims them, so the keypress went to the Clear shortcut instead and the artboard stayed. The existing tests called the tool directly and missed it. The tool now claims Delete/Backspace while no drag is in progress and more than one artboard exists. With only one artboard left, the keys stay with the shortcut. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_xform.rs | 7 +++++-- crates/tools/src/xform/artboard.rs | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index e790a65b..524adf23 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -247,10 +247,13 @@ fn artboard_tool_moves_with_art_creates_and_deletes() { assert_eq!(s.doc().unwrap().doc.artboards.len(), 2); assert_eq!(s.tool_options()["active"], 1); assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0)); - // Delete it. + // Delete it: the tool takes the key ahead of the Clear shortcut (the UI only hands Delete to a + // tool that claims it). + assert!(s.tool_claims_key(ToolKey::Delete, ViewInfo::default())); s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); - // The last artboard can't be deleted. + // The last artboard can't be deleted, so Delete stays the shortcut's. + assert!(!s.tool_claims_key(ToolKey::Delete, ViewInfo::default())); s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); assert_eq!(s.doc().unwrap().doc.artboards.len(), 1); } diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 97d7df09..e20e19cf 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -259,10 +259,19 @@ impl Tool for ArtboardTool { } } + /// Delete/Backspace remove the active artboard ahead of the Clear shortcut, which would only + /// delete selected art; with one artboard left (it can't be deleted) they stay the shortcut's. + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Delete | ToolKey::Backspace) + && self.drag.is_none() + && cx.doc.artboards.len() > 1 + && self.active < cx.doc.artboards.len() + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { match key { - ToolKey::Delete | ToolKey::Backspace if self.drag.is_none() => { - if cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() { + ToolKey::Delete | ToolKey::Backspace => { + if !self.claims_key(cx, key) { return vec![]; } let i = self.active; @@ -522,7 +531,8 @@ mod tests { let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); assert_eq!(a[1], Action::Notify("created".into())); - // Delete removes the active artboard. + // Delete removes the active artboard, ahead of the Clear shortcut. + assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace)); assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); } From b5e39048df2cbdd41e85611f52b6c67a9b70084d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 09:23:30 +0700 Subject: [PATCH 65/74] Fix #649: a panel dropped on the dock lights up where it goes Panels other than Properties, Layers and Libraries live in the dock's icon column, not as tabs. Dragging one onto the dock lit up the whole dock, tabs included, and the drop put it back as an icon, so it seemed to vanish. The drop zone now lights up the icon column for such panels (the whole dock still for the tabbed ones), and the dropped panel pops out of its icon. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/dock.rs | 1 + crates/ui-egui/src/floating.rs | 42 +++++++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 779d7c28..5827ed8e 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index f0c2c197..a202ba76 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -151,6 +151,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.ctx().data_mut(|d| { d.insert_temp(column_left_id(), column.left()); d.insert_temp(floating::dock_rect_id(), dock); + d.insert_temp(floating::icons_rect_id(), column); }); } diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 818b80f7..f7d9c5a5 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -70,6 +70,12 @@ pub(crate) fn dock_rect_id() -> Id { } /// The strip along the window's left edge where the Tools panel docks, as last laid out. +/// The icon column's bounds (egui temp memory, one frame old), where panels without a tab in the +/// tabbed group go back to when docked. +pub(crate) fn icons_rect_id() -> Id { + Id::new("floating-icons-rect") +} + pub(crate) fn tools_zone_id() -> Id { Id::new("floating-tools-zone") } @@ -216,7 +222,13 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 return Some((Drop::Stack(first), head)); } } - zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3).map(|r| (Drop::Dock, r)) + let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; + // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to + // their icons: light up the icon column, where they land, rather than the tabs. + let tabbed = !app.ui.dock_collapsed + && own.and_then(|i| app.ui.floating_panels.get(i)).is_some_and(|g| ids_of(g).into_iter().any(|p| DockTab::from_id(p).is_some())); + let lit = if tabbed { dock } else { ctx.data(|d| d.get_temp::(icons_rect_id())).unwrap_or(dock) }; + Some((Drop::Dock, lit)) } /// Light up a drop zone: over the docked panels, under the floating ones (the moved one too). @@ -262,7 +274,13 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { let ids = ids_of(g); let active = ids.get(g.active).copied().unwrap_or(id); match drop { - Drop::Dock => dock(&mut app.ui, &ids, active), + Drop::Dock => { + dock(&mut app.ui, &ids, active); + // One that went back to its icon pops out of it, so it doesn't seem to vanish. + if app.ui.dock_collapsed || DockTab::from_id(active).is_none() { + app.ui.open_panel = Some(active.to_string()); + } + } Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto), } } @@ -607,7 +625,7 @@ mod tests { h.hold(h.title("layers"), over); assert!(h.group("layers").contains(over), "the group follows the pointer"); let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over); - assert_eq!(target.map(|t| t.0), Some(Drop::Dock), "the dock is the drop zone"); + assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back"); h.release(over); assert!(h.groups().is_empty(), "docked: {:?}", h.groups()); assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown"); @@ -666,6 +684,24 @@ mod tests { assert_eq!(h.icons().len(), count - 1, "Color is back in the column"); } + #[test] + fn a_panel_dropped_on_the_dock_lights_up_and_pops_out_of_its_icon() { + let mut h = Harness::new(); + let count = h.icons().len(); + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["color"]]); + // Over the tabbed group: Color has no tab there, so the icon column lights up, not the tabs. + let (dock, column) = (h.temp_rect(dock_rect_id()), h.temp_rect(icons_rect_id())); + let over = h.dock_tabs()[1].center(); + h.hold(h.title("color"), over); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Dock, column))); + assert!(column.width() < dock.width(), "the column, not the whole dock: {column:?} {dock:?}"); + // Dropped, it's back in the column and pops out of its icon rather than vanishing. + h.release(over); + assert!(h.groups().is_empty()); + assert_eq!((h.icons().len(), h.app.ui.open_panel.as_deref()), (count, Some("color"))); + } + #[test] fn a_popped_out_panel_dragged_by_its_title_floats() { let mut h = Harness::new(); From 5be583d1f7c0e1ebd56ad63f9b2c90d03e2b9df1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 09:33:22 +0700 Subject: [PATCH 66/74] Part of #641: Image Trace: Advanced as a section that stays open, and View modes - The Image Trace panel's Advanced options sit under a full-width collapsible header (widgets::section_toggle, also used by New Document's Advanced Options); whether it is open is kept with the UI preferences. - View: Tracing Result, Tracing Result with Outlines, Outlines, Outlines with Source Image and Source Image, in the panel, the Control bar and the Properties panel. The view is saved in the Image Trace object's settings (`trace.view`), kept when it is traced again, set with the new `imageTrace.setView` command (or `view` on imageTrace.make) and drawn by the canvas only (RenderOptions::trace_views): exports, printing and Expand keep the tracing result. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/doc/src/lib.rs | 2 + crates/doc/src/node.rs | 3 +- crates/doc/src/trace.rs | 92 +++++++++++++ crates/engine/src/cmd/buildcmds.rs | 47 ++++++- crates/engine/src/tests_build.rs | 79 +++++++++++ crates/mcp/src/prompts.rs | 3 +- crates/render/src/lib.rs | 32 ++++- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/dialogs/new_document.rs | 11 +- crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/panels/image_trace.rs | 145 ++++++++++++++++++--- crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_place.rs | 19 ++- crates/ui-egui/src/tests_removeanchors.rs | 2 +- crates/ui-egui/src/widgets.rs | 15 +++ 23 files changed, 474 insertions(+), 45 deletions(-) create mode 100644 crates/doc/src/trace.rs diff --git a/ROADMAP.md b/ROADMAP.md index 779d7c28..e50bf753 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); 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), Image Trace's View modes and an Advanced section that stays as it was left (part of #641); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -143,7 +143,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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). - **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) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian 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 and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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. -- **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 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); Palette (Full Tone, Automatic, Document Library) and Create Strokes are not done), 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). 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}`). @@ -215,7 +215,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), kinsoku sets 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | +| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace Palette (Full Tone, Automatic, Document Library) and Create Strokes (Vector Halftone and the Image Trace View modes 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 presets/rearrange polish (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 | 84% | global/video rulers, Spacing Guides, 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) | 2–4 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 372a8ae9..9dd9b076 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -40,6 +40,7 @@ pub mod slices; pub mod style_libs; pub mod swatches; pub mod text; +pub mod trace; use std::collections::BTreeMap; use std::sync::Arc; @@ -89,6 +90,7 @@ pub use text::{ ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; +pub use trace::TraceView; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index c6b69904..665279b6 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -463,7 +463,8 @@ pub struct Node { /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. #[serde(default, skip_serializing_if = "Option::is_none")] pub mask: Option>, - /// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them). + /// Image Trace object: `{preset, params, view?}` it was traced with (the Image Trace panel shows + /// them; `view` is its [`crate::TraceView`] id, absent for the tracing result). #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option>, /// Object → Text Wrap: area type below this object (in the same layer) flows around it. diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs new file mode 100644 index 00000000..72dc470c --- /dev/null +++ b/crates/doc/src/trace.rs @@ -0,0 +1,92 @@ +//! Image Trace objects' View: how a live trace draws on screen. +//! +//! An Image Trace object is a group whose `trace` holds `{preset, params, view}` (see +//! `imageTrace.make`). The view is a viewing aid only: exports, printing and Expand always use the +//! tracing result, whatever it is set to. + +use crate::node::Node; + +/// Image Trace › View. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TraceView { + /// The traced shapes as they are filled. + #[default] + Result, + /// The traced shapes, with their outlines over them. + ResultWithOutlines, + /// Only the traced shapes' outlines. + Outlines, + /// The source image, with the traced shapes' outlines over it. + OutlinesWithSource, + /// Only the source image. + Source, +} + +impl TraceView { + /// Panel order. + pub const ALL: [Self; 5] = [Self::Result, Self::ResultWithOutlines, Self::Outlines, Self::OutlinesWithSource, Self::Source]; + + /// The id used in command params and in the saved `trace` object. + pub fn id(self) -> &'static str { + match self { + Self::Result => "tracingResult", + Self::ResultWithOutlines => "tracingResultWithOutlines", + Self::Outlines => "outlines", + Self::OutlinesWithSource => "outlinesWithSourceImage", + Self::Source => "sourceImage", + } + } + + /// The view an id names (any case). + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(id)) + } + + /// The view an Image Trace object's `trace` settings ask for (the tracing result when they + /// name none, or an unknown one). + pub fn of(trace: &serde_json::Value) -> Self { + trace.get("view").and_then(serde_json::Value::as_str).and_then(Self::from_id).unwrap_or_default() + } + + /// Whether the source image shows. + pub fn shows_source(self) -> bool { + matches!(self, Self::OutlinesWithSource | Self::Source) + } + + /// Whether the traced shapes show filled. + pub fn shows_result(self) -> bool { + matches!(self, Self::Result | Self::ResultWithOutlines) + } + + /// Whether the traced shapes' outlines show. + pub fn shows_outlines(self) -> bool { + matches!(self, Self::ResultWithOutlines | Self::Outlines | Self::OutlinesWithSource) + } +} + +impl Node { + /// The View of an Image Trace object (the tracing result for anything else, or an unknown id). + pub fn trace_view(&self) -> TraceView { + self.trace.as_deref().map(TraceView::of).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_round_trip_and_unknown_views_show_the_result() { + for v in TraceView::ALL { + assert_eq!(TraceView::from_id(v.id()), Some(v)); + assert_eq!(TraceView::from_id(&v.id().to_ascii_uppercase()), Some(v)); + assert!(v.shows_result() || v.shows_outlines() || v.shows_source(), "{v:?} shows something"); + } + let mut g = Node::group(crate::NodeId(1), vec![]); + assert_eq!(g.trace_view(), TraceView::Result); + g.trace = Some(Box::new(serde_json::json!({"view": "outlines"}))); + assert_eq!(g.trace_view(), TraceView::Outlines); + g.trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); + assert_eq!(g.trace_view(), TraceView::Result); + } +} diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 921d1e84..80783dca 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_pathops as po; use vectorcraft_tools::builder::{ @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -151,6 +151,15 @@ pub fn specs() -> Vec { has_selection_or_ids, trace_expand ), + cmd!( + "imageTrace.setView", + "Image Trace View", + [], + None, + "{id?|ids?: Image Trace objects (default: selection), view: \"tracingResult\"|\"tracingResultWithOutlines\"|\"outlines\"|\"outlinesWithSourceImage\"|\"sourceImage\"} how they draw on screen; exports, printing and Expand keep the tracing result → {ids, view}", + has_selection_or_ids, + trace_set_view + ), cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| { Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::>() })) }), @@ -639,6 +648,13 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +/// The `view` param ([`TraceView`] id), if given. +fn view_param(cmd: &str, p: &Value) -> Result> { + let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; + let ids = || TraceView::ALL.map(TraceView::id).join(", "); + v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) +} + fn trace_params(p: &Value) -> Result { let name = str_param(p, "preset").unwrap_or("Default"); let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?; @@ -659,6 +675,7 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000; fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; let params = trace_params(p)?; + let view = view_param(C, p)?; let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's, else "Custom". let named = str_param(p, "preset").unwrap_or("Default"); @@ -721,7 +738,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let mut g = Node::group(gid, children); g.name = if expand { None } else { Some(TRACE_NAME.into()) }; if !expand { - g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json }))); + // Tracing again keeps the object's view unless another is asked for. + let view = view.unwrap_or_else(|| src.trace_view()); + g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); } let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?; d.remove(target)?; @@ -741,6 +760,28 @@ fn trace_targets(s: &Session, p: &Value) -> Result> { Ok(v) } +fn trace_set_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "imageTrace.setView"; + let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; + let targets = trace_targets(s, p)?; + let d = &s.doc()?.doc; + if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { + return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); + } + s.edit("Image Trace View", |d, _| { + for g in &targets { + let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; + if let Some(t) = n.trace.as_deref_mut().and_then(Value::as_object_mut) { + t.insert("view".into(), json!(view.id())); + } else { + n.trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) +} + fn trace_release(s: &mut Session, p: &Value) -> Result { let targets = trace_targets(s, p)?; let ids = s.edit("Release Image Trace", |d, sel| { diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index e077ff02..0bff63c1 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -705,6 +705,85 @@ fn image_trace_object_remembers_its_settings() { assert!(st.doc.node(id).unwrap().trace.is_none()); } +/// A black disc (r = 30 px) on light blue, 100 × 100 px, placed at 2× from (50, 50): traced in +/// Black and White with Ignore White, the blue drops out and one disc (r = 60 pt at (150, 150)) +/// remains. +fn disc_on_blue(s: &mut Session) -> NodeId { + let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| { + let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0); + if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [170, 210, 255, 255] } + }); + add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0)); + let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap(); + assert_eq!(r["paths"], 1); + NodeId(r["id"].as_u64().unwrap()) +} + +/// The document rendered as the canvas draws it (`trace_views`) or as exports do. +fn render_doc(s: &Session, trace_views: bool) -> vectorcraft_render::Rendered { + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), trace_views, ..Default::default() }; + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 300, 300, Affine::IDENTITY, &opts) +} + +#[test] +fn image_trace_view_is_stored_kept_and_validated() { + let mut s = session(); + let g = disc_on_blue(&mut s); + assert_eq!(node(&s, g).trace.unwrap()["view"], "tracingResult"); + let r = s.execute("imageTrace.setView", &json!({"view": "outlinesWithSourceImage"})).unwrap(); + assert_eq!((ids(&r), r["view"].as_str()), (vec![g], Some("outlinesWithSourceImage"))); + assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource); + // Setting the view it has already adds no undo step. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("imageTrace.setView", &json!({"id": g.0, "view": "outlinesWithSourceImage"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // Tracing again keeps it, unless another one is asked for; it survives save and open. + let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true, "noise": 30}})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(node(&s, g).trace_view(), vectorcraft_doc::TraceView::OutlinesWithSource); + let g = NodeId(s.execute("imageTrace.make", &json!({"id": g.0, "view": "SourceImage"})).unwrap()["id"].as_u64().unwrap()); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(back.node(g).unwrap().trace_view(), vectorcraft_doc::TraceView::Source); + // Bad views and other objects are refused. + assert!(s.execute("imageTrace.setView", &json!({"view": "sepia"})).is_err()); + assert!(s.execute("imageTrace.setView", &json!({})).is_err()); + assert!(s.execute("imageTrace.make", &json!({"view": 3})).is_err()); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + assert!(s.execute("imageTrace.setView", &json!({"id": a.0, "view": "outlines"})).is_err()); +} + +#[test] +fn image_trace_views_draw_on_screen_only_and_expand_to_the_result() { + let mut s = session(); + let g = disc_on_blue(&mut s); + let dark = |p: [u8; 4]| p[0] < 60 && p[1] < 60 && p[2] < 60; + let white = |p: [u8; 4]| p[0] > 245 && p[1] > 245 && p[2] > 245; + let blue = |p: [u8; 4]| p[0] < 200 && p[2] > 245; + let (centre, beside) = ((150, 150), (70, 70)); + let at = |img: &vectorcraft_render::Rendered, (x, y): (u32, u32)| img.pixel(x, y); + // The outline is a 1 px line where the disc's right side is (x = 210, anti-aliased). + let outlined = |img: &vectorcraft_render::Rendered| (205..=215).any(|x| img.pixel(x, 150)[0] < 128); + let mut expanded = None; + for view in vectorcraft_doc::TraceView::ALL { + s.execute("imageTrace.setView", &json!({"id": g.0, "view": view.id()})).unwrap(); + let screen = render_doc(&s, true); + let (c, b) = (at(&screen, centre), at(&screen, beside)); + assert_eq!(dark(c), view.shows_result() || view.shows_source(), "{view:?}: the disc's middle {c:?}"); + assert_eq!(blue(b), view.shows_source(), "{view:?}: the image's background {b:?}"); + assert!(view.shows_source() || white(b), "{view:?}: nothing beside the disc {b:?}"); + if view == vectorcraft_doc::TraceView::Outlines { + assert!(outlined(&screen), "{view:?}: the disc's outline"); + } + // Exports draw the tracing result whatever the view. + let export = render_doc(&s, false); + assert!(dark(at(&export, centre)) && white(at(&export, beside)), "{view:?}: export"); + // Expand keeps the traced shapes, as traced, whatever the view. + s.execute("imageTrace.expand", &json!({"id": g.0})).unwrap(); + let shapes: Vec<_> = node(&s, g).children().unwrap().iter().map(|c| (c.path_data().cloned(), c.appearance.clone())).collect(); + assert_eq!(*expanded.get_or_insert_with(|| shapes.clone()), shapes, "{view:?}: expanded"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + /// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals /// overlapping) merges every region the drags touch into one shape whose outline is only the /// merged area's border: no edge between merged regions stays in, as a stray line or a spur. diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 737fc144..bde8fac8 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -298,7 +298,8 @@ Method: color, then threshold, colors, paths, corners, noise, method abutting or overlapping, ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers. Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths - near 90. + near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines + over the image on screen to compare them (screenshots show it; exports keep the result). 3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the catalogue and each effect's parameters. 4. set_paint over the traced shapes to give the artwork its own colours. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 52597243..dd039a13 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,7 +21,7 @@ pub mod proof; use std::collections::HashMap; use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject, TraceView}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; @@ -100,6 +100,9 @@ pub struct RenderOptions { /// Screen view: placed documents draw from cached bitmaps made in the background (see /// [`placed_document`]); off, they draw exactly, read when needed. pub progressive_placed: bool, + /// Screen view: Image Trace objects draw as their View asks (outlines, the source image…); + /// off, they draw their tracing result, as exports and printing do. + pub trace_views: bool, } /// How edges are rasterized (raster export option). @@ -165,6 +168,7 @@ impl Default for RenderOptions { highlight_substitutions: false, anti_alias: AntiAlias::Art, progressive_placed: false, + trace_views: false, } } } @@ -646,8 +650,10 @@ impl Renderer { let b = match &a.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { let mut acc: Option = None; + // An Image Trace object's hidden source image shows in some of its views. + let source = a.trace.is_some().then(|| children.first()).flatten(); for c in children { - if c.visible { + if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) { acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c)); } } @@ -883,6 +889,9 @@ impl Renderer { /// What `n` draws inside its transparency group (`knockout`: its children knock each other out). fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) { match &n.kind { + NodeKind::Group { children, clip: false } if f.opts.trace_views && !f.opts.outline && n.trace_view() != TraceView::Result => { + self.draw_trace_view(ctx, f, children, n.trace_view(), knockout) + } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { self.draw_children(ctx, f, children, knockout) } @@ -955,6 +964,25 @@ impl Renderer { } } + /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as + /// `view` shows them on screen. + fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { + let Some((source, shapes)) = children.split_first() else { return }; + if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) { + self.draw_node(ctx, f, source, true); + } + if view.shows_result() { + self.draw_children(ctx, f, shapes, knockout); + } + if view.shows_outlines() { + let opts = RenderOptions { outline: true, ..f.opts.clone() }; + let frame = Frame { opts: &opts, ..*f }; + for c in shapes { + self.draw_node(ctx, &frame, c, false); + } + } + } + /// The children of a group: as the elements of a knockout group when `knockout`. fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { if knockout { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19eb2ad6..161db60f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -284,6 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // pattern tiles stay smooth), as in Illustrator. anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, progressive_placed: true, + trace_views: true, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 7604be04..7939b963 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -441,17 +441,8 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, Strin /// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode. fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { - let t = Tokens::get(ui.ctx()); let open = d.bool("advanced"); - let resp = ui - .horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover()); - crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon); - ui.label(egui::RichText::new(tl!("Advanced Options")).color(t.text)); - }) - .response - .interact(egui::Sense::click()); - if resp.clicked() { + if widgets::section_toggle(ui, tl!("Advanced Options"), open) { d.fields.insert("advanced".into(), json!(!open)); } if !open { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6ee0e1c..9a11a035 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2487,6 +2487,13 @@ Smooth Color Color suavizado Smooth Tool Herramienta Suavizar Snap Curves To Lines Ajustar curvas a líneas + View: Vista: + Tracing Result Resultado de calco + Tracing Result with Outlines Resultado de calco con contornos + Outlines Contornos + Outlines with Source Image Contornos con imagen de origen + Source Image Imagen de origen + Image Trace View Vista del calco de imagen Snap Options Opciones de ajuste Snap to Glyph Ajustar a glifo Snap to Grid Ajustar a cuadrícula diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 135d3eec..5e4bc114 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2484,6 +2484,13 @@ Smooth Color Couleur lissée Smooth Tool Outil Arrondi Snap Curves To Lines Aimanter les courbes aux lignes + View: Affichage : + Tracing Result Résultat de la vectorisation + Tracing Result with Outlines Résultat de la vectorisation avec contours + Outlines Contours + Outlines with Source Image Contours avec l’image source + Source Image Image source + Image Trace View Affichage de la vectorisation de l’image Snap Options Options de magnétisme Snap to Glyph Aimanter au glyphe Snap to Grid Aimanter à la grille diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 39f0e052..20224024 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2487,6 +2487,13 @@ Smooth Color Colore graduale Smooth Tool Strumento Smussa Snap Curves To Lines Aggancia curve a linee + View: Vista: + Tracing Result Risultato ricalco + Tracing Result with Outlines Risultato ricalco con contorni + Outlines Contorni + Outlines with Source Image Contorni con immagine di origine + Source Image Immagine di origine + Image Trace View Vista del ricalco immagine Snap Options Opzioni di aggancio Snap to Glyph Aggancia a glifo Snap to Grid Aggancia alla griglia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 29667b31..fb9ed568 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2314,6 +2314,13 @@ Smooth スムーズ Smooth Tool スムーズツール Snap Curves To Lines 曲線を直線にスナップ + View: 表示: + Tracing Result トレース結果 + Tracing Result with Outlines トレース結果(アウトライン付き) + Outlines アウトライン + Outlines with Source Image アウトライン(元画像付き) + Source Image 元画像 + Image Trace View 画像トレースの表示 Snap Options スナップオプション Snapping Tolerance スナップ許容値 Soft Light ソフトライト diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 607911a1..2cfbbbe4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2483,6 +2483,13 @@ Smooth Color Сглаженный цвет Smooth Tool Инструмент «Сглаживание» Snap Curves To Lines Привязывать кривые к линиям + View: Просмотр: + Tracing Result Результат трассировки + Tracing Result with Outlines Результат трассировки с контурами + Outlines Контуры + Outlines with Source Image Контуры с исходным изображением + Source Image Исходное изображение + Image Trace View Просмотр трассировки изображения Snap Options Параметры привязки Snap to Glyph Привязать к глифу Snap to Grid Привязать к сетке diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2fe75d6..2404890f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2424,6 +2424,13 @@ Smooth Color 平滑颜色 Smooth Tool 平滑工具 Snap Curves To Lines 将曲线对齐到直线 + View: 视图: + Tracing Result 描摹结果 + Tracing Result with Outlines 描摹结果(带轮廓) + Outlines 轮廓 + Outlines with Source Image 轮廓(带源图像) + Source Image 源图像 + Image Trace View 图像描摹视图 Snap Options 对齐选项 Snap to Glyph 靠齐字符 Snap to Grid 对齐网格 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b23c0fd5..3f0985b3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2507,6 +2507,13 @@ Smooth Color 平滑顏色 Smooth Tool 平滑工具 Snap Curves To Lines 將曲線靠齊線條 + View: 檢視: + Tracing Result 描圖結果 + Tracing Result with Outlines 描圖結果(含外框) + Outlines 外框 + Outlines with Source Image 外框(含來源影像) + Source Image 來源影像 + Image Trace View 影像描圖檢視 Snap Options 靠齊選項 Snap to Glyph 靠齊字符 Snap to Grid 靠齊格點 diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index bda45ab0..41c60892 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -1,10 +1,12 @@ -//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options. -//! With an Image Trace object selected, changing a setting re-traces it (one undo step per change; -//! sliders apply when released). With an image selected, Trace makes a new Image Trace object. +//! Image Trace panel: preset, view, mode, threshold or colour count, and the Advanced section's +//! fidelity options (open or closed as it was last left). With an Image Trace object selected, +//! changing a setting re-traces it (one undo step per change; sliders apply when released) and +//! changing the view redraws it without tracing again. With an image selected, Trace makes a new +//! Image Trace object. use egui::Ui; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Node, NodeKind, TraceView}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; @@ -12,11 +14,12 @@ use crate::widgets::{self, menu_item}; const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")]; -/// Panel state: the preset name, the current parameters and the last result's counts. +/// Panel state: the preset name, the current parameters, the view and the last result's counts. #[derive(Clone, Default)] struct TraceUi { preset: String, params: Value, + view: TraceView, info: Option<(u64, u64, u64)>, /// The selected Image Trace object's stored settings last adopted (so edits in progress /// aren't overwritten until the object changes). @@ -44,12 +47,44 @@ fn target(app: &VectorcraftApp) -> (bool, bool, Option) { (is_trace(&n), matches!(n.kind, NodeKind::Image(_)), n.trace.map(|t| *t)) } -/// The preset of the one selected Image Trace object ("Custom" once its settings were changed). -pub(crate) fn selected_preset(app: &VectorcraftApp) -> Option { +/// The one selected Image Trace object's preset ("Custom" once its settings were changed) and view. +pub(crate) fn selected_trace(app: &VectorcraftApp) -> Option<(String, TraceView)> { let st = app.session.active()?; let [id] = st.selection.objects[..] else { return None }; let n = st.doc.node(id).filter(|n| is_trace(n))?; - Some(n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string()) + Some((n.trace.as_ref().and_then(|t| t["preset"].as_str()).unwrap_or("Custom").to_string(), n.trace_view())) +} + +/// Image Trace › View, in the UI language. +fn view_label(v: TraceView) -> &'static str { + match v { + TraceView::Result => tl!("Tracing Result"), + TraceView::ResultWithOutlines => tl!("Tracing Result with Outlines"), + TraceView::Outlines => tl!("Outlines"), + TraceView::OutlinesWithSource => tl!("Outlines with Source Image"), + TraceView::Source => tl!("Source Image"), + } +} + +/// The View dropdown showing `current` → the view chosen. +fn view_dropdown(ui: &mut Ui, current: TraceView, width: f32) -> Option { + let labels = TraceView::ALL.map(view_label); + widgets::dropdown_names(ui, "trace-view", view_label(current), &labels, width).and_then(|i| TraceView::ALL.get(i).copied()) +} + +/// Show the selected Image Trace objects with `view` (no new trace). +fn set_view(app: &mut VectorcraftApp, view: TraceView) { + if let Err(e) = app.run("imageTrace.setView", json!({ "view": view.id() })) { + app.ui.status = e; + } +} + +/// The selected Image Trace object's View row (Control bar, Properties). +pub(crate) fn view_row(app: &mut VectorcraftApp, ui: &mut Ui, current: TraceView, width: f32) { + widgets::dim_label(ui, tl!("View:")); + if let Some(v) = view_dropdown(ui, current, width) { + set_view(app, v); + } } /// Trace the selected image, or trace the selected Image Trace object again, with built-in `preset`. @@ -94,12 +129,13 @@ pub(crate) fn preset_dropdown(app: &mut VectorcraftApp, ui: &mut Ui, current: &s } } -/// The Control bar for one selected Image Trace object: its preset, the Image Trace panel and -/// Expand. Whether it is one. +/// The Control bar for one selected Image Trace object: its preset, view, the Image Trace panel +/// and Expand. Whether it is one. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { - let Some(preset) = selected_preset(app) else { return false }; + let Some((preset, view)) = selected_trace(app) else { return false }; widgets::dim_label(ui, tl!("Preset:")); preset_dropdown(app, ui, &preset, 150.0); + view_row(app, ui, view, 190.0); if widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() { app.ui.open_panel = Some("imageTrace".into()); } @@ -112,7 +148,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { fn trace(app: &mut VectorcraftApp, st: &mut TraceUi) { let preset = if st.preset == "Custom" { "Default" } else { st.preset.as_str() }; - match app.run("imageTrace.make", json!({ "preset": preset, "params": st.params })) { + match app.run("imageTrace.make", json!({ "preset": preset, "params": st.params, "view": st.view.id() })) { Ok(r) => st.info = Some((r["paths"].as_u64().unwrap_or(0), r["anchors"].as_u64().unwrap_or(0), r["colors"].as_u64().unwrap_or(0))), Err(e) => app.ui.status = e, } @@ -143,6 +179,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(t) = stored.filter(|t| *t != st.synced) { st.preset = t["preset"].as_str().unwrap_or("Custom").to_string(); st.params = t["params"].clone(); + st.view = TraceView::of(&t); st.synced = t; } let mut retrace = false; @@ -156,6 +193,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { retrace = true; } }); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("View:")); + if let Some(v) = view_dropdown(ui, st.view, 170.0) { + st.view = v; + if is_trace { + set_view(app, v); + } + } + }); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Mode:")); let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string(); @@ -181,9 +227,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } retrace |= release; - let open: bool = !pstate::(ui.ctx(), "it-advanced-closed"); - if ui.add(egui::Button::new(format!("{} {}", if open { "▾" } else { "▸" }, tl!("Advanced"))).frame(false)).clicked() { - set_pstate(ui.ctx(), "it-advanced-closed", open); + ui.add_space(4.0); + let open = app.ui.image_trace_advanced; + if widgets::section_toggle(ui, tl!("Advanced"), open) { + app.ui.image_trace_advanced = !open; } if open { for (key, label, range, suffix) in @@ -258,6 +305,73 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + + /// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + } + + /// Click the panel's `label` (the first one drawn). + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), press(true)]); + frame(app, ctx, vec![press(false)]); + } + + fn traced_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); + app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); + app + } + + #[test] + fn advanced_is_a_section_that_stays_as_it_was_left() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let texts = frame(&mut app, &ctx, vec![]); + assert!(app.ui.image_trace_advanced && has(&texts, "Advanced") && has(&texts, "Corners"), "open at first: {texts:?}"); + click(&mut app, &ctx, "Advanced"); + assert!(!app.ui.image_trace_advanced, "the header closes it"); + assert!(!has(&frame(&mut app, &ctx, vec![]), "Corners")); + // Remembered with the UI preferences, across launches. + let saved: crate::state::UiState = serde_json::from_value(serde_json::to_value(&app.ui).unwrap()).unwrap(); + assert!(!saved.image_trace_advanced); + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert!(old.image_trace_advanced, "open for preferences saved before the section existed"); + click(&mut app, &ctx, "Advanced"); + assert!(app.ui.image_trace_advanced && has(&frame(&mut app, &ctx, vec![]), "Corners")); + } + + #[test] + fn the_view_dropdown_shows_and_sets_the_objects_view() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame(&mut app, &ctx, vec![]); + assert!(texts.iter().any(|(t, _)| t == "View:") && texts.iter().any(|(t, _)| t == "Tracing Result"), "{texts:?}"); + let steps = app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, "Tracing Result"); + click(&mut app, &ctx, "Source Image"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(TraceView::Source)); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step, no new trace"); + // Another object's view is shown when it is selected. + app.run("imageTrace.setView", json!({"view": "outlines"})).unwrap(); + assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Outlines")); + } #[test] fn panel_and_menu_draw_headless() { @@ -266,6 +380,7 @@ mod tests { app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); for _ in 0..2 { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { show(&mut app, ui); menu(&mut app, ui); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index b74bc2ba..e24c9c9f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -44,8 +44,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { image_section(app, ui); divider(ui); } - if let Some(preset) = crate::panels::image_trace::selected_preset(app) { - trace_section(app, ui, &preset); + if let Some((preset, view)) = crate::panels::image_trace::selected_trace(app) { + trace_section(app, ui, &preset, view); divider(ui); } if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) { @@ -162,13 +162,14 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The one selected Image Trace object: its preset (choosing another traces it again with that -/// one), Expand and Release. -fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str) { +/// one), view, Expand and Release. +fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str, view: vectorcraft_doc::TraceView) { section_header(ui, tl!("Image Trace")); ui.horizontal(|ui| { dim_label(ui, tl!("Preset:")); crate::panels::image_trace::preset_dropdown(app, ui, preset, ui.available_width()); }); + ui.horizontal(|ui| crate::panels::image_trace::view_row(app, ui, view, ui.available_width())); let w = (ui.available_width() - 6.0) / 2.0; ui.horizontal(|ui| { for (label, id) in [(tl!("Expand"), "imageTrace.expand"), (tl!("Release"), "imageTrace.release")] { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b6af00be..be105654 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -365,6 +365,9 @@ pub struct UiState { /// Layers panel › Panel Options… (row size, thumbnails, Show Layers Only). #[serde(default)] pub layers_panel: crate::panels::layers::PanelOptions, + /// The Image Trace panel's Advanced section is open (as it was last left). + #[serde(default = "yes")] + pub image_trace_advanced: bool, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -518,6 +521,7 @@ impl Default for UiState { dxf_import: Value::Null, home: None, layers_panel: Default::default(), + image_trace_advanced: true, window: None, } } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index f23ea87c..dc3bc806 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -308,7 +308,8 @@ fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { #[test] fn the_control_bar_and_properties_trace_a_selected_image() { - use crate::panels::image_trace::{TRACE_BUTTON_W, selected_preset}; + use crate::panels::image_trace::{TRACE_BUTTON_W, selected_trace}; + let selected_preset = |app: &VectorcraftApp| selected_trace(app).map(|t| t.0); use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame}; let mut app = app(); let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0)); @@ -332,19 +333,27 @@ fn the_control_bar_and_properties_trace_a_selected_image() { app.run("edit.undo", json!({})).unwrap(); click_label(&mut app, &ctx, "Image Trace"); assert_eq!(selected_preset(&app).as_deref(), Some("Default")); - // An Image Trace object: its preset (another traces it again), the panel and Expand. + // An Image Trace object: its preset (another traces it again), view, the panel and Expand. let bar = control_frame(&mut app, &ctx, vec![]); - for s in ["Image Tracing", "Preset:", "Default", "Expand"] { + for s in ["Image Tracing", "Preset:", "Default", "View:", "Tracing Result", "Expand"] { assert!(has(&bar, s), "{s}"); } - assert!(has(&properties_frame(&mut app, 260.0), "Release")); + let props = properties_frame(&mut app, 260.0); + assert!(has(&props, "Release") && has(&props, "View:")); + // Another view redraws it without tracing again. + let traced = app.session.active().unwrap().selection.objects.clone(); + click_control(&mut app, &ctx, at(&bar, "Tracing Result")); + click_label(&mut app, &ctx, "Outlines with Source Image"); + assert_eq!(selected_trace(&app).map(|t| t.1), Some(vectorcraft_doc::TraceView::OutlinesWithSource)); + assert_eq!(app.session.active().unwrap().selection.objects, traced, "the same object"); + let bar = control_frame(&mut app, &ctx, vec![]); click_control(&mut app, &ctx, at(&bar, "Default")); click_label(&mut app, &ctx, "3 Colors"); assert_eq!(selected_preset(&app).as_deref(), Some("3 Colors")); click_label(&mut app, &ctx, "Expand"); let st = app.session.active().unwrap(); let g = st.doc.node(st.selection.objects[0]).unwrap(); - assert!(selected_preset(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded"); + assert!(selected_trace(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded"); } #[test] diff --git a/crates/ui-egui/src/tests_removeanchors.rs b/crates/ui-egui/src/tests_removeanchors.rs index 0baee6c3..80a7dcc3 100644 --- a/crates/ui-egui/src/tests_removeanchors.rs +++ b/crates/ui-egui/src/tests_removeanchors.rs @@ -37,7 +37,7 @@ fn labels(items: &[Item]) -> Vec<&'static str> { .collect() } -fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { +pub(crate) fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) { match s { Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 78f45b48..58be7f37 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -121,6 +121,21 @@ pub fn section_header(ui: &mut Ui, text: &str) { ui.add_space(2.0); } +/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`; +/// a click anywhere on it toggles the section. Whether it was clicked. +pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); + ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(text), theme::semibold(12.5), t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, tl!(text))); + resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + /// Full-width 1 px divider with vertical margins. pub fn divider(ui: &mut Ui) { let t = Tokens::get(ui.ctx()); From 4a81e549a236f3847c65cf1f1146906aa98c3680 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 09:51:49 +0700 Subject: [PATCH 67/74] Part of #641: Image Trace: Palette (Limited, Full Tone, Automatic, Document Library) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color mode's colours now come from a Palette (`palette` in imageTrace.make's params), shown in the Image Trace panel under Mode: - Limited: the `colors` best colours (what Color did before; the default). - Full Tone: median cut until no colour box spans more than 96 (Less) … 8 (More) levels per channel, up to 256 colours, set by `colorDetail` 0-100 (the panel's Colors slider for this palette). - Automatic: flat art (90% of the pixels in a few tight colours) traces with exactly its colours, anything else as Full Tone. - Document Library: the most used (at most `colors`) colours of the document's swatches or of a swatch library (`library`: "document" or a library id or name, chosen in the panel's Library list), used as they are; traced shapes fill with the swatch's own colour, its colour model kept. The library's colours are looked up at every trace, not saved. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/cmd/buildcmds.rs | 33 +++++- crates/engine/src/cmd/swatchlib.rs | 11 +- crates/engine/src/tests_build.rs | 43 +++++++ crates/mcp/src/prompts.rs | 4 +- crates/trace/src/lib.rs | 49 +++++++- crates/trace/src/quantize.rs | 130 ++++++++++++++++++--- crates/trace/src/tests.rs | 90 +++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/image_trace.rs | 132 ++++++++++++++++++++-- crates/ui-egui/src/tests_removeanchors.rs | 2 +- 17 files changed, 496 insertions(+), 39 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5827ed8e..4e399fa5 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); 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 Palette: Limited, Full Tone, Automatic and Document Library (part of #641); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -143,7 +143,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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). - **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) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian 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 and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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. -- **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; 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; the View modes and Create Strokes are not done), 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). 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}`). @@ -215,7 +215,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), kinsoku sets 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | +| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) and Create Strokes (Vector Halftone and the Image Trace Palette 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 presets/rearrange polish (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 | 84% | global/video rulers, Spacing Guides, 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) | 2–4 | diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 921d1e84..3091e95f 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -651,6 +651,30 @@ fn trace_params(p: &Value) -> Result { serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string())) } +/// Document Library in Color mode: look the library's colours up into `params.swatches` → each +/// one as traced (sRGB) with the colour to fill with. +fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result> { + const C: &str = "imageTrace.make"; + if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { + return Ok(vec![]); + } + let key = params.library.as_str(); + let colors = super::swatchlib::library_colors(s, key) + .ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?; + if colors.is_empty() { + return Err(bad(C, format!("`{key}` has no colours"))); + } + let library: Vec<([u8; 3], Color)> = colors + .into_iter() + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], c) + }) + .collect(); + params.swatches = library.iter().map(|l| l.0).collect(); + Ok(library) +} + /// The most anchor points an Image Trace may make: a photo traced at high fidelity can make /// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is /// refused with advice instead (#525). @@ -658,7 +682,7 @@ pub const MAX_TRACE_ANCHORS: usize = 2_000_000; fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; - let params = trace_params(p)?; + let mut params = trace_params(p)?; let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; // The preset's name if the parameters are exactly that preset's, else "Custom". let named = str_param(p, "preset").unwrap_or("Default"); @@ -687,6 +711,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { }; let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?; let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let library = trace_library(s, &mut params)?; let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?; // Pixel space of the decoded raster → the image object's pixel space → document. let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; @@ -714,7 +739,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { for tp in res.paths { let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); - n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2]))); + // A library colour fills with the swatch's own colour (its model kept). + let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); + n.appearance = fill_only(Paint::solid(color)); children.push(Arc::new(n)); } let gid = d.alloc_id(); diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 35d39516..00462b8e 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -276,11 +276,18 @@ pub const DOCUMENT_SWATCHES: &str = "document"; /// [`DOCUMENT_SWATCHES`], of the active document's swatches and colour groups. `None` when there is /// no such library (or document). pub fn limit_palette(s: &Session, key: &str) -> Option { + Some(Palette::new(library_colors(s, key)?)) +} + +/// The solid colours of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the +/// active document's swatches and colour groups. `None` when there is no such library (or +/// document). +pub fn library_colors(s: &Session, key: &str) -> Option> { let colors = |sw: &Swatch| sw.paint.color(); if key == DOCUMENT_SWATCHES { - return Some(Palette::new(s.active()?.doc.swatches_iter().filter_map(colors))); + return Some(s.active()?.doc.swatches_iter().filter_map(colors).collect()); } - Some(Palette::new(library(s, key)?.1.iter().filter_map(colors))) + Some(library(s, key)?.1.iter().filter_map(colors).collect()) } /// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for ""). diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index e077ff02..af34cc5e 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -669,6 +669,49 @@ fn image_trace_make_and_expand_and_color_presets() { assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); } +#[test] +fn image_trace_palettes_and_swatch_libraries() { + let mut s = session(); + let quads = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, true) => [230, 20, 20, 255], + (false, true) => [20, 200, 30, 255], + (true, false) => [20, 30, 220, 255], + (false, false) => [250, 230, 10, 255], + }); + add_image(&mut s, &quads, Affine::IDENTITY); + let fills = |s: &Session, id: &Value| -> Vec { + traced_paths(&node(s, NodeId(id.as_u64().unwrap()))).iter().filter_map(|n| n.appearance.fill_paint().color()).collect() + }; + // Document Library: every fill is one of the document's swatch colours, as it is. + let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"palette": "documentLibrary"}})).unwrap(); + let doc = crate::cmd::swatchlib::library_colors(&s, "document").unwrap(); + let got = fills(&s, &r["id"]); + assert!(!got.is_empty() && got.iter().all(|c| doc.contains(c)), "{got:?}"); + let t = node(&s, NodeId(r["id"].as_u64().unwrap())).trace.unwrap(); + assert_eq!((t["params"]["palette"].as_str(), t["params"]["library"].as_str()), (Some("documentLibrary"), Some("document"))); + assert!(t["params"].get("swatches").is_none(), "looked up again at every trace"); + // A swatch library by id: the web-safe colours. + let r = s + .execute( + "imageTrace.make", + &json!({"id": r["id"], "preset": "6 Colors", "params": {"palette": "documentLibrary", "library": "web-safe-216", "colors": 3}}), + ) + .unwrap(); + let got = fills(&s, &r["id"]); + assert!((1..=3).contains(&got.len()), "{got:?}"); + assert!(got.iter().all(|c| c.to_rgba8(1.0)[..3].iter().all(|v| v % 51 == 0)), "{got:?}"); + // Full Tone and Automatic keep the four flat colours. + let mut id = r["id"].clone(); + for palette in ["fullTone", "automatic"] { + let r = s.execute("imageTrace.make", &json!({"id": id, "preset": "6 Colors", "params": {"palette": palette, "colorDetail": 50}})).unwrap(); + assert_eq!(r["colors"], 4, "{palette}"); + id = r["id"].clone(); + } + let mut bad = |p: Value| s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": p})).is_err(); + assert!(bad(json!({"palette": "documentLibrary", "library": "No Such Library"}))); + assert!(bad(json!({"palette": "rainbow"}))); +} + #[test] fn image_trace_presets_query_and_errors() { let mut s = session(); diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 737fc144..2b0a9a77 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -295,7 +295,9 @@ with the {effect} effect. Method: 1. open_file the image: it becomes a document of its own pixel size. 2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or - color, then threshold, colors, paths, corners, noise, method abutting or overlapping, + color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with + the document's swatches or a swatch `library`), then threshold, colors (colorDetail for + fullTone and automatic), paths, corners, noise, method abutting or overlapping, ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers. Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths near 90. diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 03495b25..8c36535a 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -4,8 +4,11 @@ //! and classic colour quantisation), not from any existing implementation: //! //! 1. **Quantise** the image into a small palette: a luminance threshold (Black and White), 1-D -//! k-means over the luminance histogram (Grayscale), or median cut followed by weighted -//! k-means over a 15-bit colour histogram (Color). Transparent pixels are left out. +//! k-means over the luminance histogram (Grayscale), or, in Color, over a 15-bit colour +//! histogram as the [`Palette`] asks: median cut to a colour count followed by weighted k-means +//! (Limited), median cut until every colour box is tight (Full Tone), one of those two by +//! whether the image is flat art (Automatic), or the most used colours of a swatch library +//! (Document Library). Transparent pixels are left out. //! 2. **Remove noise**: 4-connected same-colour components smaller than `noise` pixels are merged //! into their most common neighbouring colour. //! 3. **Follow boundaries**: for each colour layer, the pixel-crack edges between inside and @@ -34,7 +37,7 @@ use vectorcraft_geom::PathData; pub use contour::{Component, Loop, trace_mask}; pub use mosaic::mosaic; -pub use quantize::{Quantized, TRANSPARENT, denoise, quantize}; +pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; /// Errors decoding a raster. #[derive(Debug, thiserror::Error)] @@ -132,6 +135,28 @@ pub enum Method { Overlapping, } +/// Where Color mode's colours come from (the Palette popup). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Palette { + /// The `colors` colours that best represent the image. + #[default] + #[serde(alias = "Limited")] + Limited, + /// As many colours as the image's tones need, up to [`FULL_TONE_MAX`] (photos, gradients): + /// `color_detail` sets how close two colours may be and stay apart. + #[serde(alias = "Full Tone")] + FullTone, + /// Flat art (a few colours cover nearly all of it) traces with exactly those colours, anything + /// else as Full Tone. + #[serde(alias = "Automatic")] + Automatic, + /// The most used colours of a swatch library ([`TraceParams::swatches`]), at most `colors` of + /// them, exactly as they are. + #[serde(alias = "library", alias = "Document Library")] + DocumentLibrary, +} + /// Image Trace parameters (the Image Trace panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -139,8 +164,20 @@ pub struct TraceParams { pub mode: Mode, /// Black and White: pixels darker than this (luminance 0–255) are black. pub threshold: u8, - /// Palette size for Color, number of grays for Grayscale (2–256). + /// Color: where the colours come from. + pub palette: Palette, + /// Palette size for Color (Limited; at most this many for Document Library), number of grays + /// for Grayscale (2–256). pub colors: u32, + /// Full Tone and Automatic: how many colours, 0 (few) – 100 (many). + pub color_detail: f64, + /// Document Library: the swatch library, `"document"` (the document's swatches) or a library id + /// or name. The caller looks its colours up into `swatches`. + pub library: String, + /// Document Library: the library's colours (sRGB). Not saved: they are looked up from + /// `library` at every trace. + #[serde(skip)] + pub swatches: Vec<[u8; 3]>, /// Paths (fidelity) 0–100: higher follows the pixels more tightly. pub paths: f64, /// Corners 0–100: higher keeps more corners. @@ -160,7 +197,11 @@ impl Default for TraceParams { Self { mode: Mode::BlackAndWhite, threshold: 128, + palette: Palette::Limited, colors: 6, + color_detail: 50.0, + library: "document".into(), + swatches: vec![], paths: 50.0, corners: 75.0, noise: 25, diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 6b596cc8..96de7897 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -1,6 +1,6 @@ //! Colour reduction and noise removal. -use crate::{Mode, Raster, TraceParams}; +use crate::{Mode, Palette, Raster, TraceParams}; /// Label of a transparent (untraced) pixel. pub const TRANSPARENT: u16 = u16::MAX; @@ -67,10 +67,8 @@ pub fn quantize(img: &Raster, params: &TraceParams) -> Quantized { b.sum[2] += p[2] as u64; } } - let used: Vec<(usize, [f64; 3], f64)> = - bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); - let k = params.colors.clamp(2, 256) as usize; - let centers = kmeans_rgb(&used, k); + let used: Vec = bins.iter().enumerate().filter(|(_, b)| b.n > 0).map(|(i, b)| (i, b.mean(), b.n as f64)).collect(); + let centers = color_palette(&used, params); let mut lut = vec![0u16; 1 << 15]; for (i, c, _) in &used { lut[*i] = nearest_rgb(¢ers, *c) as u16; @@ -187,12 +185,108 @@ fn nearest_rgb(centers: &[[f64; 3]], c: [f64; 3]) -> usize { best.1 } -/// Median cut for the initial palette, then weighted k-means refinement over histogram bins. -fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> { - if used.is_empty() { - return vec![[255.0; 3]]; +/// The most colours Full Tone (and Automatic on continuous-tone images) makes. +pub const FULL_TONE_MAX: usize = 256; + +/// A histogram bin in use: its index, mean colour and pixel count. +type Used = (usize, [f64; 3], f64); + +/// Color mode's palette for the histogram bins `used`, as `params.palette` asks. +fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3]> { + let k = params.colors.clamp(2, 256) as usize; + let detail = (params.color_detail / 100.0).clamp(0.0, 1.0); + // Full Tone: split until no colour box is wider than 96 (few colours) … 8 (many) levels per + // channel, which is one histogram bin. + let full_tone = || refine(used, centers(&median_cut(used, FULL_TONE_MAX, 96.0 - 88.0 * detail))); + match params.palette { + Palette::Limited => kmeans_rgb(used, k), + Palette::FullTone => full_tone(), + Palette::Automatic => match flat_colors(used, detail) { + Some(n) => kmeans_rgb(used, n), + None => full_tone(), + }, + Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, ¶ms.swatches, k), + // No library colours to use: as Limited. + Palette::DocumentLibrary => kmeans_rgb(used, k), + } +} + +/// Automatic: how many colours flat art is made of, or `None` for continuous tone (photos, +/// gradients). Flat art has most of its pixels in a few tight colours (anti-aliased edges and +/// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly +/// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own. +fn flat_colors(used: &[Used], detail: f64) -> Option { + let total: f64 = used.iter().map(|u| u.2).sum(); + if total <= 0.0 { + return Some(2); + } + let mut by_count: Vec<&Used> = used.iter().collect(); + by_count.sort_by(|a, b| b.2.total_cmp(&a.2)); + let mut covered = 0.0; + let main = by_count + .iter() + .take_while(|u| { + let before = covered; + covered += u.2; + before < 0.9 * total + }) + .count(); + if main > FLAT_BINS { + return None; + } + let boxes = median_cut(used, 64, 24.0); + let min_share = (0.04 - 0.035 * detail) * total; + let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect(); + let weight: f64 = colours.iter().map(|b| b.weight).sum(); + let peaks: f64 = colours.iter().map(|b| b.peak).sum(); + (weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2)) +} + +/// The most histogram bins flat art's main colours spread over (a few bins per colour). +const FLAT_BINS: usize = 96; + +/// Document Library: the `k` swatches (at most) that the most pixels are nearest to. +fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> { + let mut all: Vec<[f64; 3]> = vec![]; + for s in swatches { + let c = s.map(f64::from); + if !all.contains(&c) { + all.push(c); + } + } + let mut weight = vec![0.0; all.len()]; + for (_, c, n) in used { + if let Some(w) = weight.get_mut(nearest_rgb(&all, *c)) { + *w += n; + } + } + let mut order: Vec = (0..all.len()).collect(); + order.sort_by(|a, b| weight[*b].total_cmp(&weight[*a]).then(a.cmp(b))); + order.into_iter().take(k.max(1)).filter_map(|i| all.get(i).copied()).collect() +} + +/// A median-cut box: its weighted mean colour, pixel count and the pixel count of its heaviest bin. +struct ColorBox { + mean: [f64; 3], + weight: f64, + peak: f64, +} + +fn centers(boxes: &[ColorBox]) -> Vec<[f64; 3]> { + boxes.iter().map(|b| b.mean).collect() +} + +/// Median cut to `k` colours, then weighted k-means refinement over histogram bins. +fn kmeans_rgb(used: &[Used], k: usize) -> Vec<[f64; 3]> { + refine(used, centers(&median_cut(used, k, 0.0))) +} + +/// Median cut: repeatedly split the box with the largest weighted extent, while there are fewer +/// than `k` boxes and some box spans more than `spread` levels of a channel. +fn median_cut(used: &[Used], k: usize, spread: f64) -> Vec { + if used.is_empty() { + return vec![ColorBox { mean: [255.0; 3], weight: 0.0, peak: 0.0 }]; } - // Median cut: repeatedly split the box with the largest weighted extent. let mut boxes: Vec> = vec![(0..used.len()).collect()]; while boxes.len() < k { let mut best: Option<(f64, usize, usize)> = None; @@ -210,7 +304,7 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> { wsum += used[i].2; } let (ch, range) = (0..3).map(|c| (c, hi[c] - lo[c])).fold((0, -1.0), |a, b| if b.1 > a.1 { b } else { a }); - if range <= 0.0 { + if range <= spread.max(0.0) { continue; } let score = range * wsum.sqrt(); @@ -235,21 +329,25 @@ fn kmeans_rgb(used: &[(usize, [f64; 3], f64)], k: usize) -> Vec<[f64; 3]> { boxes.push(b); boxes.push(tail); } - let mut centers: Vec<[f64; 3]> = boxes + boxes .iter() .map(|b| { let mut s = [0.0; 3]; - let mut w = 0.0; + let (mut w, mut peak) = (0.0, 0.0f64); for &i in b { for (acc, v) in s.iter_mut().zip(used[i].1) { *acc += v * used[i].2; } w += used[i].2; + peak = peak.max(used[i].2); } - [s[0] / w, s[1] / w, s[2] / w] + ColorBox { mean: [s[0] / w, s[1] / w, s[2] / w], weight: w, peak } }) - .collect(); - // Lloyd refinement. + .collect() +} + +/// Lloyd refinement of `centers` over the bins `used`. +fn refine(used: &[Used], mut centers: Vec<[f64; 3]>) -> Vec<[f64; 3]> { for _ in 0..12 { let mut sum = vec![[0.0; 3]; centers.len()]; let mut cnt = vec![0.0; centers.len()]; diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 7d51ad68..4f09a137 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -348,3 +348,93 @@ fn an_image_too_large_to_trace_is_refused_before_decoding() { assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}"); assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding"); } + +/// Four flat colours with anti-aliased (blended) edges between the quadrants. +fn flat_quads() -> Raster { + const C: [[u8; 3]; 4] = [[220, 30, 30], [30, 200, 40], [30, 40, 210], [240, 220, 20]]; + Raster::from_fn(100, 100, |x, y| { + let at = |x: u32, y: u32| C[usize::from(x >= 50) + 2 * usize::from(y >= 50)]; + let (a, b) = (at(x, y), at(x.saturating_sub(1), y.saturating_sub(1))); + // Blend the pixels along the quadrant edges, as an anti-aliased picture has them. + let c: Vec = a.iter().zip(b).map(|(p, q)| ((u16::from(*p) + u16::from(q)) / 2) as u8).collect(); + [c[0], c[1], c[2], 255] + }) +} + +/// A smooth two-dimensional colour gradient (continuous tone). +fn gradient() -> Raster { + Raster::from_fn(128, 128, |x, y| [(x * 2) as u8, (y * 2) as u8, (255 - x - y / 2) as u8, 255]) +} + +fn palette(img: &Raster, palette: Palette, colors: u32, color_detail: f64) -> Vec<[u8; 3]> { + let p = TraceParams { mode: Mode::Color, palette, colors, color_detail, noise: 4, ..TraceParams::default() }; + trace(img, &p).palette +} + +#[test] +fn palettes_parse_from_their_ids_and_names() { + for (id, want) in [ + ("limited", Palette::Limited), + ("fullTone", Palette::FullTone), + ("Automatic", Palette::Automatic), + ("documentLibrary", Palette::DocumentLibrary), + ("library", Palette::DocumentLibrary), + ] { + let p: TraceParams = serde_json::from_value(serde_json::json!({ "palette": id })).unwrap(); + assert_eq!(p.palette, want, "{id}"); + } + assert_eq!(TraceParams::default().palette, Palette::Limited); + // The library's colours are looked up at every trace, not saved with the settings. + let v = serde_json::to_value(TraceParams { swatches: vec![[1, 2, 3]], ..TraceParams::default() }).unwrap(); + assert!(v.get("swatches").is_none() && v["library"] == "document" && v["colorDetail"] == 50.0, "{v}"); +} + +#[test] +fn full_tone_follows_the_tones_the_image_has() { + // Flat art keeps its few colours (and the tones of its blended edges); a gradient gets many, + // more with more detail. + let flat = palette(&flat_quads(), Palette::FullTone, 6, 50.0); + assert!((4..=10).contains(&flat.len()), "flat art: {flat:?}"); + let few = palette(&gradient(), Palette::FullTone, 6, 10.0).len(); + let many = palette(&gradient(), Palette::FullTone, 6, 90.0).len(); + assert!(few > 6 && many > few && many <= FULL_TONE_MAX, "{few} then {many} colours"); +} + +#[test] +fn automatic_traces_flat_art_with_its_colours_and_photos_in_full_tone() { + let flat = palette(&flat_quads(), Palette::Automatic, 2, 50.0); + assert_eq!(flat.len(), 4, "the four flat colours, whatever `colors` says: {flat:?}"); + assert!(flat.iter().any(|c| c[0] > 200 && c[1] < 60 && c[2] < 60), "red is one of them: {flat:?}"); + let tone = palette(&gradient(), Palette::Automatic, 2, 50.0); + assert_eq!(tone.len(), palette(&gradient(), Palette::FullTone, 2, 50.0).len(), "continuous tone: as Full Tone"); +} + +#[test] +fn document_library_uses_the_most_used_swatches_exactly() { + let swatches = vec![[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 0, 0], [255, 255, 255]]; + let p = |colors| TraceParams { + mode: Mode::Color, + palette: Palette::DocumentLibrary, + colors, + swatches: swatches.clone(), + noise: 4, + ..TraceParams::default() + }; + let hard = Raster::from_fn(100, 100, |x, y| match (x < 50, y < 50) { + (true, true) => [220, 30, 30, 255], + (false, true) => [30, 200, 40, 255], + (true, false) => [30, 40, 210, 255], + (false, false) => [240, 220, 20, 255], + }); + let res = trace(&hard, &p(6)); + let mut got = res.palette.clone(); + got.sort_unstable(); + assert_eq!(got, [[0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0]], "the nearest swatch of each colour, as it is"); + assert!(res.paths.iter().all(|t| swatches.contains(&t.color))); + // At most `colors` of them: the ones the most pixels are nearest to. + let two = trace(&hard, &p(2)).palette; + assert!(two.len() <= 2 && two.iter().all(|c| swatches.contains(c)), "{two:?}"); + // Without library colours, it traces as Limited. + let none = TraceParams { swatches: vec![], ..p(4) }; + assert_eq!(trace(&hard, &none).palette.len(), 4); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6ee0e1c..606c5951 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1152,6 +1152,11 @@ Ignore Ignorar Ignore Artboards (all the art as one page) Ignorar mesas de trabajo (toda la ilustración como una página) Ignore White Ignorar blanco + Palette: Paleta: + Limited Limitada + Full Tone Tono completo + Document Library Biblioteca del documento + Library: Biblioteca: Image Imagen Image Info Información de imagen Image Map: Mapa de imagen: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 135d3eec..57ce3814 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1151,6 +1151,11 @@ Ignore Ignorer Ignore Artboards (all the art as one page) Ignorer les plans de travail (toute l’illustration sur une page) Ignore White Ignorer le blanc + Palette: Palette : + Limited Limitée + Full Tone Tons continus + Document Library Bibliothèque du document + Library: Bibliothèque : Image Image Image Info Informations sur l’image Image Map: Image cliquable : diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 39f0e052..07b421d8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1151,6 +1151,11 @@ Ignore Ignora Ignore Artboards (all the art as one page) Ignora tavole da disegno (tutta la grafica come una sola pagina) Ignore White Ignora bianco + Palette: Palette: + Limited Limitata + Full Tone Tono continuo + Document Library Libreria del documento + Library: Libreria: Image Immagine Image Info Informazioni immagine Image Map: Mappa immagine: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 29667b31..613809b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1410,6 +1410,11 @@ Ignore 無視 Ignore Artboards (all the art as one page) アートボードを無視 (すべてのアートを 1 ページとして扱う) Ignore White 白を無視 + Palette: パレット: + Limited 限定 + Full Tone フルトーン + Document Library ドキュメントライブラリ + Library: ライブラリ: Image Info 画像情報 Image Map: イメージマップ: Image Size イメージサイズ diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 607911a1..38d8dd9f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1149,6 +1149,11 @@ Ignore Игнорировать Ignore Artboards (all the art as one page) Игнорировать монтажные области (весь рисунок как одна страница) Ignore White Игнорировать белый + Palette: Палитра: + Limited Ограниченная + Full Tone Полутоновая + Document Library Библиотека документа + Library: Библиотека: Image Изображение Image Info Сведения об изображении Image Map: Карта изображения: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2fe75d6..bccbcde5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1140,6 +1140,11 @@ Ignore 忽略 Ignore Artboards (all the art as one page) 忽略画板 (所有图稿作为一页) Ignore White 忽略白色 + Palette: 调板: + Limited 有限 + Full Tone 全色调 + Document Library 文档库 + Library: 库: Image 图像 Image Info 图像信息 Image Map: 图像映射: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b23c0fd5..b1ca20cf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1169,6 +1169,11 @@ Ignore 略過 Ignore Artboards (all the art as one page) 忽略工作區域 (所有圖稿視為單一頁面) Ignore White 忽略白色 + Palette: 調色盤: + Limited 有限 + Full Tone 全色調 + Document Library 文件資料庫 + Library: 資料庫: Image 影像 Image Info 影像資訊 Image Map: 影像地圖: diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index bda45ab0..dece1e5c 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -1,17 +1,50 @@ -//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options. +//! Image Trace panel: preset, mode, palette (Color), threshold or colour count, and the advanced +//! fidelity options. //! With an Image Trace object selected, changing a setting re-traces it (one undo step per change; //! sliders apply when released). With an image selected, Trace makes a new Image Trace object. use egui::Ui; use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES; -use super::{first_selected, pstate, set_pstate}; +use super::{first_selected, library_panel, pstate, set_pstate, swatches}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; const MODES: [(&str, &str); 3] = [("blackAndWhite", "Black and White"), ("grayscale", "Grayscale"), ("color", "Color")]; +/// Color mode's palettes (`palette` param ids), in panel order. +const PALETTES: [&str; 4] = ["limited", "fullTone", "automatic", "documentLibrary"]; + +/// Palette `id`, in the UI language. +fn palette_label(id: &str) -> &'static str { + match id { + "fullTone" => tl!("Full Tone"), + "automatic" => tl!("Automatic"), + "documentLibrary" => tl!("Document Library"), + _ => tl!("Limited"), + } +} + +/// The name of Document Library's library `key` (the document's swatches or a swatch library); a +/// library that is gone shows its key. +fn library_label(app: &VectorcraftApp, key: &str) -> String { + match swatches::limit_name(app, key) { + Some(_) if key == DOCUMENT_SWATCHES => tl!(swatches::DOCUMENT_SWATCHES).to_string(), + Some(name) => library_panel::library_name(key, &name).to_string(), + None => key.to_string(), + } +} + +/// Document Library's choices: the document's swatches, then the colour libraries (`current` +/// checked) → the key chosen. +fn library_list(app: &VectorcraftApp, ui: &mut Ui, current: &str) -> Option { + let document = menu_item(ui, swatches::DOCUMENT_SWATCHES, true, current == DOCUMENT_SWATCHES).then(|| DOCUMENT_SWATCHES.to_string()); + ui.separator(); + library_panel::library_items(ui, &swatches::colour_libraries(app), Some(current)).or(document) +} + /// Panel state: the preset name, the current parameters and the last result's counts. #[derive(Clone, Default)] struct TraceUi { @@ -167,16 +200,41 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { retrace = true; } }); - let bw = st.params["mode"].as_str() == Some("blackAndWhite"); - let (key, label, range) = if bw { - ("threshold", tl!("Threshold"), 0.0..=255.0) - } else { - ("colors", if st.params["mode"] == "grayscale" { tl!("Grays") } else { tl!("Colors") }, 2.0..=256.0) + let mode = st.params["mode"].as_str().unwrap_or("blackAndWhite").to_string(); + let palette = st.params["palette"].as_str().unwrap_or("limited").to_string(); + if mode == "color" { + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Palette:")); + let labels = PALETTES.map(palette_label); + if let Some(p) = widgets::dropdown_names(ui, "it-palette", palette_label(&palette), &labels, 170.0).and_then(|i| PALETTES.get(i)) { + st.params["palette"] = json!(p); + st.preset = "Custom".into(); + retrace = true; + } + }); + if palette == "documentLibrary" { + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Library:")); + let key = st.params["library"].as_str().unwrap_or(DOCUMENT_SWATCHES).to_string(); + if let Some(k) = widgets::combo(ui, "it-library", &library_label(app, &key), 170.0, false, |ui| library_list(app, ui, &key)) { + st.params["library"] = json!(k); + st.preset = "Custom".into(); + retrace = true; + } + }); + } + } + // Full Tone and Automatic: how many colours (Less … More); else a count or the threshold. + let (key, label, range, suffix) = match mode.as_str() { + "blackAndWhite" => ("threshold", tl!("Threshold"), 0.0..=255.0, ""), + "grayscale" => ("colors", tl!("Grays"), 2.0..=256.0, ""), + _ if matches!(palette.as_str(), "fullTone" | "automatic") => ("colorDetail", tl!("Colors"), 0.0..=100.0, "%"), + _ => ("colors", tl!("Colors"), 2.0..=256.0, ""), }; let mut v = st.params[key].as_f64().unwrap_or(0.0); - let (changed, release) = slider(ui, label, &mut v, range, ""); + let (changed, release) = slider(ui, label, &mut v, range, suffix); if changed { - st.params[key] = json!(v.round() as u64); + st.params[key] = if key == "colorDetail" { json!(v.round()) } else { json!(v.round() as u64) }; st.preset = "Custom".into(); } retrace |= release; @@ -258,6 +316,61 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + + /// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + } + + /// Click the panel's `label` (the first one drawn). + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), press(true)]); + frame(app, ctx, vec![press(false)]); + } + + fn traced_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); + app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); + app + } + + #[test] + fn the_palette_shows_in_color_mode_and_traces_again() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s); + let palette = |app: &VectorcraftApp| target(app).2.map(|t| t["params"]["palette"].clone()); + assert!(!has(&frame(&mut app, &ctx, vec![]), "Palette:"), "Black and White has no palette"); + app.run("imageTrace.make", json!({"preset": "6 Colors"})).unwrap(); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Palette:") && has(&texts, "Limited") && !has(&texts, "Library:"), "{texts:?}"); + // Document Library traces again with the document's swatches, and names them. + click(&mut app, &ctx, "Limited"); + click(&mut app, &ctx, "Document Library"); + assert_eq!(palette(&app), Some(json!("documentLibrary"))); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "Library:") && has(&texts, "Document Swatches"), "{texts:?}"); + // Full Tone's Colors is how many, Less (0%) … More (100%). + click(&mut app, &ctx, "Document Library"); + click(&mut app, &ctx, "Full Tone"); + assert_eq!(palette(&app), Some(json!("fullTone"))); + let texts = frame(&mut app, &ctx, vec![]); + assert!(has(&texts, "50") && !has(&texts, "Library:"), "{texts:?}"); + } #[test] fn panel_and_menu_draw_headless() { @@ -266,6 +379,7 @@ mod tests { app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); for _ in 0..2 { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { show(&mut app, ui); menu(&mut app, ui); diff --git a/crates/ui-egui/src/tests_removeanchors.rs b/crates/ui-egui/src/tests_removeanchors.rs index 0baee6c3..80a7dcc3 100644 --- a/crates/ui-egui/src/tests_removeanchors.rs +++ b/crates/ui-egui/src/tests_removeanchors.rs @@ -37,7 +37,7 @@ fn labels(items: &[Item]) -> Vec<&'static str> { .collect() } -fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { +pub(crate) fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) { match s { Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), From 438f90c75184054bbaaefae9f48916a0a9ca7d4d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 10:11:09 +0700 Subject: [PATCH 68/74] Part of #641: Image Trace: Create Strokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advanced › Create: Fills and/or Strokes, with Stroke (the widest line, px). With Strokes, each colour's 8-connected parts no wider than the stroke width (chamfer distance transform) trace as open centre lines instead of filled outlines: the line is thinned to one pixel (Zhang-Suen with each removal re-checked, so two-pixel lines survive; staircase corners dropped), its skeleton becomes runs between ends and junctions (junction pixels merged, spurs pruned, runs through two-way junctions joined, rings closed), and each run is simplified and fitted with curves. The stroke width is the line's mean width (pixels over length). Traced lines get round caps and joins in the document. Without Fills, wider areas become 1 px stroked outlines. `imageTrace.make` takes fills, strokes and strokeWidth, and refuses neither fills nor strokes and a non-positive width. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/engine/src/cmd/buildcmds.rs | 29 +- crates/engine/src/tests_build.rs | 35 +- crates/mcp/src/prompts.rs | 3 +- crates/trace/src/centerline.rs | 379 ++++++++++++++++++++++ crates/trace/src/fit.rs | 43 +++ crates/trace/src/lib.rs | 56 +++- crates/trace/src/tests.rs | 114 +++++++ crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/image_trace.rs | 76 ++++- crates/ui-egui/src/tests_removeanchors.rs | 2 +- 17 files changed, 746 insertions(+), 18 deletions(-) create mode 100644 crates/trace/src/centerline.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5827ed8e..4a602d1e 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); 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 Create Strokes: line art traced as stroked centre lines (part of #641); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -143,7 +143,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **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). - **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) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian 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 and Russian 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 (the strict set) applies to horizontal and vertical Japanese, 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 wrapped line flush with its start, 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. -- **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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), 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 and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; 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; the View modes are not done), 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). 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}`). @@ -215,7 +215,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), kinsoku sets 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), Image Trace View modes (Outlines, Source Image) (Vector Halftone is done) | 6–10 | +| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), Image Trace View modes (Outlines, Source Image), per-feature stroke/fill choice and varying widths in Create Strokes (Vector Halftone is 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 presets/rearrange polish (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 | 84% | global/video rulers, Spacing Guides, 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) | 2–4 | diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 921d1e84..dd6f985a 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer}; +use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_pathops as po; @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}} → {id, paths, anchors, colors}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -648,7 +648,14 @@ fn trace_params(p: &Value) -> Result { for (k, val) in src { o.insert(k.clone(), val.clone()); } - serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string())) + let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?; + if !params.fills && !params.strokes { + return Err(bad("imageTrace.make", "Create needs fills, strokes or both")); + } + if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { + return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels")); + } + Ok(params) } /// The most anchor points an Image Trace may make: a photo traced at high fidelity can make @@ -692,6 +699,8 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let sx = img.width.max(1) as f64 / raster.width.max(1) as f64; let sy = img.height.max(1) as f64 / raster.height.max(1) as f64; let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy); + // Document units per raster pixel (stroke widths). + let unit = xf.determinant().abs().sqrt(); let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len()); let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" }; let id = s.edit(label, |d, sel| { @@ -714,7 +723,19 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { for tp in res.paths { let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); - n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2]))); + let paint = Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])); + n.appearance = match tp.stroke { + // Traced lines: round ends and corners, as drawn lines have. + Some(width) => Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: LineCap::Round, + join: LineJoin::Round, + ..StrokeLayer::new(paint, width * unit) + })], + ..Default::default() + }, + None => fill_only(paint), + }; children.push(Arc::new(n)); } let gid = d.alloc_id(); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index e077ff02..d8ab296d 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -669,6 +669,39 @@ fn image_trace_make_and_expand_and_color_presets() { assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); } +#[test] +fn image_trace_create_strokes_makes_stroked_centre_lines() { + let mut s = session(); + // A 3 px wide horizontal line from x = 10 to 90 at y = 50, placed at 2× from (100, 100). + let r = + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| if (10..90).contains(&x) && (49..52).contains(&y) { [0, 0, 0, 255] } else { [255; 4] }); + add_image(&mut s, &r, Affine::translate((100.0, 100.0)) * Affine::scale(2.0)); + let res = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "strokes": true, "strokeWidth": 5}})).unwrap(); + assert_eq!(res["paths"], 1); + let g = NodeId(res["id"].as_u64().unwrap()); + let paths = traced_paths(&node(&s, g)); + let line = &paths[0]; + assert!(line.appearance.fill_paint().color().is_none(), "not filled"); + let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked"); + assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width); + assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round)); + assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0))); + let sp = &line.path_data().unwrap().subpaths[0]; + assert!(!sp.closed); + let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); + assert!((a.y - 201.0).abs() < 1.5 && (b.y - 201.0).abs() < 1.5 && (a.x - b.x).abs() > 140.0, "{a:?} … {b:?}"); + let t = node(&s, g).trace.unwrap(); + assert_eq!((t["params"]["strokes"].as_bool(), t["params"]["strokeWidth"].as_f64()), (Some(true), Some(5.0))); + // Expand keeps the stroked line. + s.execute("imageTrace.expand", &json!({})).unwrap(); + assert!(node(&s, g).children().unwrap()[0].appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))); + // Create needs Fills or Strokes, and a stroke width. + s.execute("edit.undo", &json!({})).unwrap(); + for p in [json!({"fills": false, "strokes": false}), json!({"strokes": true, "strokeWidth": 0}), json!({"strokeWidth": -3})] { + assert!(s.execute("imageTrace.make", &json!({"id": g.0, "params": p})).is_err(), "{p}"); + } +} + #[test] fn image_trace_presets_query_and_errors() { let mut s = session(); diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 737fc144..f6739496 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -296,7 +296,8 @@ Method: 1. open_file the image: it becomes a document of its own pixel size. 2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or color, then threshold, colors, paths, corners, noise, method abutting or overlapping, - ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers. + ignoreWhite, snapCurvesToLines; for line art, strokes: true (with strokeWidth, the widest + line in pixels) traces lines as stroked centre lines instead of filled outlines. Run imageTrace.presets to see every preset with its numbers. Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths near 90. 3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the diff --git a/crates/trace/src/centerline.rs b/crates/trace/src/centerline.rs new file mode 100644 index 00000000..eaf12c37 --- /dev/null +++ b/crates/trace/src/centerline.rs @@ -0,0 +1,379 @@ +//! Create Strokes: the parts of a colour layer no wider than the Stroke width, traced as stroked +//! centre lines instead of filled outlines. +//! +//! 1. **Distance**: a chamfer (3-4) distance transform gives each pixel its distance to the nearest +//! pixel outside the layer. +//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less +//! one) is within the stroke width is a line; wider ones stay filled areas. +//! 3. **Skeleton**: the line is thinned to one pixel (Zhang–Suen, applied pixel by pixel so it +//! keeps its topology and two-pixel-wide lines don't vanish), then staircase corners that +//! don't connect anything are dropped. +//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels +//! merged into one point) become polylines through the pixel centres; rings without ends +//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs +//! on either side of a junction left with two of them are joined. +//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke +//! width is the line's mean width (its pixel count over its length). + +use std::collections::HashSet; + +use vectorcraft_geom::{PathData, Point}; + +use crate::fit::{self, FitOptions}; + +/// One line of a colour layer: its centre lines and the width to stroke them with. +pub(crate) struct Line { + pub path: PathData, + /// Stroke width in pixels. + pub width: f64, + pub pixels: usize, +} + +/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width` +/// pixels, and which pixels they cover (to leave out of the filled areas). +pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec, Vec) { + let mut thin = vec![false; labels.len()]; + let mut out = vec![]; + if w == 0 || h == 0 || labels.len() != w * h { + return (thin, out); + } + let mask = |i: usize| labels.get(i) == Some(&label); + let dt = distance(&mask, w, h); + let mut seen = vec![false; w * h]; + let mut stack = vec![]; + for start in 0..w * h { + if !mask(start) || seen[start] { + continue; + } + let mut comp = vec![]; + seen[start] = true; + stack.push(start); + while let Some(i) = stack.pop() { + comp.push(i); + for j in neighbours(i, w, h) { + if mask(j) && !seen[j] { + seen[j] = true; + stack.push(j); + } + } + } + let widest = 2.0 * f64::from(comp.iter().map(|&i| dt[i]).max().unwrap_or(0)) / 3.0 - 1.0; + if widest > max_width { + continue; + } + let Some(line) = trace_line(&comp, w, max_width, o) else { continue }; + for &i in &comp { + thin[i] = true; + } + out.push(line); + } + (thin, out) +} + +/// The 8 neighbours of pixel `i` inside the `w` × `h` image. +fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator { + let (x, y) = ((i % w) as i64, (i / w) as i64); + const AROUND: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]; + AROUND.into_iter().filter_map(move |(dx, dy)| { + let (nx, ny) = (x + dx, y + dy); + (nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64).then(|| ny as usize * w + nx as usize) + }) +} + +/// Chamfer 3-4 distance (3 per pixel step, saturating) of each pixel in `mask` to the nearest +/// pixel outside it; outside the image counts as outside. +fn distance(mask: &impl Fn(usize) -> bool, w: usize, h: usize) -> Vec { + let mut d = vec![0u16; w * h]; + let at = |d: &[u16], x: i64, y: i64| { + if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0 } else { d.get(y as usize * w + x as usize).copied().unwrap_or(0) } + }; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if mask(i) { + let (x, y) = (x as i64, y as i64); + let steps = [(x - 1, y, 3), (x - 1, y - 1, 4), (x, y - 1, 3), (x + 1, y - 1, 4)]; + d[i] = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0); + } + } + } + for y in (0..h).rev() { + for x in (0..w).rev() { + let i = y * w + x; + if mask(i) { + let (xi, yi) = (x as i64, y as i64); + let steps = [(xi + 1, yi, 3), (xi + 1, yi + 1, 4), (xi, yi + 1, 3), (xi - 1, yi + 1, 4)]; + let v = steps.iter().map(|&(nx, ny, c)| at(&d, nx, ny).saturating_add(c)).min().unwrap_or(0); + d[i] = d[i].min(v); + } + } + } + d +} + +/// A grid around one line: its pixels with a one-pixel empty border. +struct Grid { + on: Vec, + w: usize, + h: usize, + /// Image position of grid pixel (0, 0). + x0: i64, + y0: i64, +} + +impl Grid { + /// The 8 neighbours (N, NE, E, SE, S, SW, W, NW) of interior pixel `i`. + fn ring(&self, i: usize) -> [bool; 8] { + let w = self.w; + let g = |j: usize| self.on.get(j).copied().unwrap_or(false); + [g(i - w), g(i - w + 1), g(i + 1), g(i + w + 1), g(i + w), g(i + w - 1), g(i - 1), g(i - w - 1)] + } + fn interior(&self) -> impl Iterator + use<> { + let (w, h) = (self.w, self.h); + (1..h.saturating_sub(1)).flat_map(move |y| (1..w.saturating_sub(1)).map(move |x| y * w + x)) + } + fn neighbours(&self, i: usize) -> impl Iterator + '_ { + let w = self.w; + [i - w, i - w + 1, i + 1, i + w + 1, i + w, i + w - 1, i - 1, i - w - 1].into_iter().filter(|&j| self.on.get(j).copied().unwrap_or(false)) + } + fn degree(&self, i: usize) -> usize { + self.neighbours(i).count() + } + /// The centre of pixel `i` in image coordinates. + fn centre(&self, i: usize) -> Point { + Point::new((self.x0 + (i % self.w) as i64) as f64 + 0.5, (self.y0 + (i / self.w) as i64) as f64 + 0.5) + } +} + +/// Zhang–Suen thinning. Each sub-iteration picks the pixels to remove from the same state, so the +/// skeleton stays centred; they are then removed one by one, each only if it still doesn't +/// disconnect anything, so a two-pixel-wide line thins to one pixel instead of vanishing. +fn thin(g: &mut Grid) { + let removable = |p: &[bool; 8]| { + let b = p.iter().filter(|v| **v).count(); + let a = (0..8).filter(|&k| !p[k] && p[(k + 1) % 8]).count(); + (2..=6).contains(&b) && a == 1 + }; + let mut picked = vec![]; + loop { + let mut changed = false; + for step in 0..2 { + picked.clear(); + for i in g.interior() { + if !g.on[i] { + continue; + } + let p = g.ring(i); + let (n, e, s, w) = (p[0], p[2], p[4], p[6]); + let side = if step == 0 { !(e && s && (n || w)) } else { !(n && w && (e || s)) }; + if side && removable(&p) { + picked.push(i); + } + } + for &i in &picked { + if removable(&g.ring(i)) { + g.on[i] = false; + changed = true; + } + } + } + if !changed { + break; + } + } + // Staircase corners: a pixel whose neighbours stay connected without it (Yokoi's + // 8-connectivity number 1) and that doesn't end the line. + loop { + let mut changed = false; + for i in g.interior() { + if !g.on[i] || g.degree(i) < 2 { + continue; + } + let p = g.ring(i); + // x1 = E, x2 = NE, x3 = N, x4 = NW, x5 = W, x6 = SW, x7 = S, x8 = SE. + let x = [p[2], p[1], p[0], p[7], p[6], p[5], p[4], p[3]]; + let off = |k: usize| !x[k % 8]; + let c8: usize = [0, 2, 4, 6].iter().map(|&k| usize::from(off(k)) - usize::from(off(k) && off(k + 1) && off(k + 2))).sum(); + if c8 == 1 { + g.on[i] = false; + changed = true; + } + } + if !changed { + break; + } + } +} + +/// A run of skeleton pixels between two nodes (`None`: none, a ring). +#[derive(Clone)] +struct Run { + pts: Vec, + ends: [Option; 2], + closed: bool, +} + +impl Run { + fn length(&self) -> f64 { + self.pts.windows(2).map(|p| p[0].distance(p[1])).sum::() + + if self.closed { self.pts.first().zip(self.pts.last()).map_or(0.0, |(a, b)| a.distance(*b)) } else { 0.0 } + } +} + +/// The centre lines of one line component (pixel indices of the `w`-wide image). +fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option { + let xs = comp.iter().map(|&i| i % w); + let ys = comp.iter().map(|&i| i / w); + let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?); + let (gw, gh) = (x1 - x0 + 3, y1 - y0 + 3); + let mut g = Grid { on: vec![false; gw * gh], w: gw, h: gh, x0: x0 as i64 - 1, y0: y0 as i64 - 1 }; + for &i in comp { + g.on[(i / w - y0 + 1) * gw + (i % w - x0 + 1)] = true; + } + thin(&mut g); + let skeleton: Vec = g.interior().filter(|&i| g.on[i]).collect(); + if skeleton.len() < 2 { + return None; + } + // Nodes: line ends, and junctions with the junction pixels touching them. + let mut node = vec![usize::MAX; g.on.len()]; + let mut centres: Vec = vec![]; + for &i in &skeleton { + let d = g.degree(i); + if d == 2 || node[i] != usize::MAX { + continue; + } + let id = centres.len(); + let mut members = vec![i]; + node[i] = id; + if d > 2 { + let mut k = 0; + while let Some(&p) = members.get(k) { + k += 1; + for q in g.neighbours(p).collect::>() { + if node[q] == usize::MAX && g.degree(q) > 2 { + node[q] = id; + members.push(q); + } + } + } + } + let sum = members.iter().fold(Point::ZERO, |s, &m| s + g.centre(m).to_vec2()); + centres.push(Point::new(sum.x / members.len() as f64, sum.y / members.len() as f64)); + } + // Runs from each node, then the rings left. + let mut used: HashSet<(usize, usize)> = HashSet::new(); + let edge = |a: usize, b: usize| (a.min(b), a.max(b)); + let mut runs: Vec = vec![]; + for &p in skeleton.iter().filter(|&&i| node[i] != usize::MAX) { + for q in g.neighbours(p).collect::>() { + if node[q] == node[p] || !used.insert(edge(p, q)) { + continue; + } + let mut pts = vec![centres[node[p]], g.centre(q)]; + let (mut prev, mut cur) = (p, q); + while node[cur] == usize::MAX { + let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) else { break }; + used.insert(edge(cur, next)); + pts.push(g.centre(next)); + (prev, cur) = (cur, next); + } + let end = (node[cur] != usize::MAX).then_some(node[cur]); + if let (Some(e), Some(last)) = (end, pts.last_mut()) { + *last = centres[e]; + } + runs.push(Run { pts, ends: [Some(node[p]), end], closed: false }); + } + } + for &s in &skeleton { + if node[s] != usize::MAX || g.neighbours(s).any(|n| used.contains(&edge(s, n))) { + continue; + } + let mut pts = vec![g.centre(s)]; + let (mut prev, mut cur) = (usize::MAX, s); + let mut closed = false; + while let Some(next) = g.neighbours(cur).find(|&n| n != prev && !used.contains(&edge(cur, n))) { + used.insert(edge(cur, next)); + if next == s { + closed = true; + break; + } + pts.push(g.centre(next)); + (prev, cur) = (cur, next); + } + runs.push(Run { pts, ends: [None, None], closed }); + } + // The line's mean width: its pixels over its length (the skeleton stops about half a width + // short of each end). + let width = |runs: &[Run]| { + let length: f64 = runs.iter().map(Run::length).sum(); + let px = comp.len() as f64; + ((-length + (length * length + 4.0 * px).sqrt()) / 2.0).clamp(1.0, max_width.max(1.0)) + }; + let spur = width(&runs); + let runs = join(prune(runs, centres.len(), spur), centres.len()); + let width = width(&runs); + let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect(); + if subs.is_empty() { + return None; + } + Some(Line { path: PathData::new(subs), width, pixels: comp.len() }) +} + +/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end +/// to a junction that keeps at least two other runs. +fn prune(runs: Vec, nodes: usize, width: f64) -> Vec { + let mut degree = vec![0usize; nodes]; + for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) { + degree[*e] += 1; + } + let mut order: Vec = (0..runs.len()).collect(); + order.sort_by(|a, b| runs[*a].length().total_cmp(&runs[*b].length())); + let mut keep = vec![true; runs.len()]; + for i in order { + let r = &runs[i]; + let [Some(a), Some(b)] = r.ends else { continue }; + let (tip, fork) = if degree[a] == 1 { (a, b) } else { (b, a) }; + if degree[tip] == 1 && degree[fork] >= 3 && r.length() < width.max(2.0) { + keep[i] = false; + degree[tip] -= 1; + degree[fork] -= 1; + } + } + runs.into_iter().zip(keep).filter_map(|(r, k)| k.then_some(r)).collect() +} + +/// The runs joined through every node exactly two of them meet at (a run meeting itself closes). +fn join(runs: Vec, nodes: usize) -> Vec { + let mut runs: Vec> = runs.into_iter().map(Some).collect(); + for n in 0..nodes { + let at: Vec<(usize, usize)> = runs + .iter() + .enumerate() + .flat_map(|(i, r)| r.iter().flat_map(move |r| (0..2).filter(move |&k| r.ends[k] == Some(n)).map(move |k| (i, k)))) + .collect(); + let [(i, ki), (j, kj)] = at[..] else { continue }; + if i == j { + if let Some(r) = runs[i].as_mut() { + r.pts.pop(); + r.ends = [None, None]; + r.closed = r.pts.len() >= 3; + } + continue; + } + let (Some(mut a), Some(mut b)) = (runs[i].take(), runs[j].take()) else { continue }; + // a ends at n, b starts at n. + if ki == 0 { + a.pts.reverse(); + a.ends.reverse(); + } + if kj == 1 { + b.pts.reverse(); + b.ends.reverse(); + } + a.pts.extend(b.pts.into_iter().skip(1)); + a.ends[1] = b.ends[1]; + runs[i] = Some(a); + } + runs.into_iter().flatten().collect() +} diff --git a/crates/trace/src/fit.rs b/crates/trace/src/fit.rs index 22c54793..c3928b97 100644 --- a/crates/trace/src/fit.rs +++ b/crates/trace/src/fit.rs @@ -133,6 +133,49 @@ pub(crate) fn fit_loop(l: &Loop, o: &FitOptions) -> Option { Some(sp) } +/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the +/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A +/// `closed` line runs back to its first point. +pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option { + let n = pts.len(); + let first = *pts.first()?; + if n < 2 { + return None; + } + let mut ext = pts.to_vec(); + let mut keep = vec![false; n + 1]; + keep[0] = true; + if closed { + // Split the ring at its first point and the point farthest from it. + ext.push(first); + let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2); + keep[far] = true; + rdp(&ext, 0, far, o.polygon_tol, &mut keep); + rdp(&ext, far, n, o.polygon_tol, &mut keep); + } else { + keep[n - 1] = true; + rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep); + } + let mut poly: Vec = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect(); + if closed && poly.len() < 3 { + poly = pts.to_vec(); + } + if poly.len() < if closed { 3 } else { 2 } { + return None; + } + let sp = SubPath::polyline(&poly, closed); + let fitted = + simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false }); + let mut sp = fitted.subpaths.into_iter().next()?; + if sp.anchors.len() < 2 { + return None; + } + if o.snap_lines { + snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5)); + } + Some(sp) +} + /// Retract the handles of curves that stay within `tol` of their chord. fn snap_to_lines(sp: &mut SubPath, tol: f64) { let n = sp.anchors.len(); diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 03495b25..e0983d55 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -18,10 +18,15 @@ //! 5. **Curves**: the polygon is fitted with cubic Béziers (`vectorcraft_pathops::simplify_with`, //! least squares with corner detection); optionally nearly-straight curves snap to lines. //! +//! With **Create: Strokes**, the parts of a colour layer no wider than the stroke width are traced +//! as stroked centre lines instead (see `centerline`); without Fills, wider parts become stroked +//! outlines. +//! //! Colour layers are traced either *abutting* (each colour's own area; shapes share edges) or //! *overlapping* (stacked: each layer also covers every layer above it, so no hairline gaps). #![forbid(unsafe_code)] +mod centerline; mod contour; mod fit; mod mosaic; @@ -152,6 +157,12 @@ pub struct TraceParams { pub ignore_white: bool, /// Replace nearly-straight curves with straight lines. pub snap_curves_to_lines: bool, + /// Create: trace areas as filled shapes. + pub fills: bool, + /// Create: trace lines no wider than `stroke_width` as stroked centre lines. + pub strokes: bool, + /// Stroke: the widest feature (px) traced as a stroke. + pub stroke_width: f64, } impl Default for TraceParams { @@ -167,6 +178,9 @@ impl Default for TraceParams { method: Method::Abutting, ignore_white: false, snap_curves_to_lines: false, + fills: true, + strokes: false, + stroke_width: 10.0, } } } @@ -250,7 +264,8 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> { PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect() } -/// One traced shape: an outer contour plus its holes, filled with `color`. +/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`) +/// lines stroked with it. #[derive(Clone, Debug, PartialEq)] pub struct TracedPath { /// In pixel coordinates (x right, y down; the image spans `0..width × 0..height`). @@ -258,6 +273,9 @@ pub struct TracedPath { pub color: [u8; 3], /// Filled pixel count of the component the path came from. pub pixels: usize, + /// Create Strokes: the path is stroked this wide (px), not filled (centre lines, or the + /// outlines of wider areas without Fills). + pub stroke: Option, } /// The traced result, bottom-most path first. @@ -357,15 +375,37 @@ impl Layers<'_> { let (params, opts) = (self.params, &self.opts); let color = *self.q.palette.get(ci)?; let overlapping = params.method == Method::Overlapping; + let (w, h) = (self.w, self.h); + // Create Strokes: this colour's lines, left out of its areas. + let (thin, lines) = if params.strokes { + centerline::lines(&self.q.labels, u16::try_from(ci).unwrap_or(TRANSPARENT), w, h, params.stroke_width, opts) + } else { + (vec![], vec![]) + }; let mask: Vec = self .q .labels .iter() - .map(|&l| l != TRANSPARENT && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci }) + .enumerate() + .map(|(i, &l)| { + l != TRANSPARENT + && !thin.get(i).copied().unwrap_or(false) + && if overlapping { self.rank.get(l as usize).is_some_and(|&lr| lr >= r) } else { l as usize == ci } + }) .collect(); + // Without Fills, areas are outlined with a 1 px stroke. + let area_stroke = (params.strokes && !params.fills).then_some(1.0); let min_hole = params.noise.max(1) as i64; let mut paths = vec![]; - for comp in trace_mask(&mask, self.w, self.h) { + let mut add = |path: PathData, pixels: usize, stroke: Option| { + let n = path.anchor_count(); + if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { + return false; + } + paths.push(TracedPath { path, color, pixels, stroke }); + true + }; + for comp in trace_mask(&mask, w, h) { let Some(outer) = fit::fit_loop(&comp.outer, opts) else { continue }; let mut subs = vec![outer]; for hole in &comp.holes { @@ -374,12 +414,14 @@ impl Layers<'_> { } subs.extend(fit::fit_loop(hole, opts)); } - let path = PathData::new(subs); - let n = path.anchor_count(); - if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors { + if !add(PathData::new(subs), comp.pixels, area_stroke) { + return None; + } + } + for line in lines { + if !add(line.path, line.pixels, Some(line.width)) { return None; } - paths.push(TracedPath { path, color, pixels: comp.pixels }); } Some(paths) } diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 7d51ad68..47648905 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_geom::Point; const BLACK: [u8; 4] = [0, 0, 0, 255]; const WHITE: [u8; 4] = [255, 255, 255, 255]; @@ -348,3 +349,116 @@ fn an_image_too_large_to_trace_is_refused_before_decoding() { assert!(matches!(err, TraceError::TooLarge { width: 30_000, height: 30_000, .. }), "{err}"); assert!(matches!(Raster::decode(&png_header(64, 64)), Err(TraceError::Decode(_))), "a small header goes on to decoding"); } + +fn strokes(fills: bool) -> TraceParams { + TraceParams { strokes: true, fills, stroke_width: 6.0, noise: 4, ..bw() } +} + +/// Endpoints (first and last anchor) of each open subpath of the stroked paths. +fn stroked(res: &TraceResult) -> Vec<(&TracedPath, &vectorcraft_geom::SubPath)> { + res.paths.iter().filter(|p| p.stroke.is_some()).flat_map(|p| p.path.subpaths.iter().map(move |s| (p, s))).collect() +} + +#[test] +fn a_thin_line_traces_as_one_stroked_centre_line() { + // A 3 px wide diagonal line from (20, 20) to (180, 100). + let img = Raster::from_fn(200, 120, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let t = (((px - 20.0) * 160.0 + (py - 20.0) * 80.0) / (160.0f64.powi(2) + 80.0f64.powi(2))).clamp(0.0, 1.0); + let (cx, cy) = (20.0 + 160.0 * t, 20.0 + 80.0 * t); + if (px - cx).hypot(py - cy) <= 1.5 { BLACK } else { WHITE } + }); + let res = trace(&img, &strokes(true)); + assert_eq!(res.paths.len(), 1, "{:?}", res.paths.iter().map(|p| p.stroke).collect::>()); + let p = &res.paths[0]; + let width = p.stroke.expect("stroked"); + assert!((2.0..=4.0).contains(&width), "width {width}"); + assert_eq!(p.path.subpaths.len(), 1); + let sp = &p.path.subpaths[0]; + assert!(!sp.closed); + let (a, b) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); + let (a, b) = if a.x < b.x { (a, b) } else { (b, a) }; + assert!(a.distance(Point::new(20.0, 20.0)) < 3.0 && b.distance(Point::new(180.0, 100.0)) < 3.0, "{a:?} … {b:?}"); + // A straight line: a few anchors, all on it. + assert!(sp.anchors.len() <= 4, "{} anchors", sp.anchors.len()); + // Without Create Strokes, the same line is a filled outline. + let filled = trace(&img, &bw()); + assert!(filled.paths.iter().all(|p| p.stroke.is_none()) && filled.paths[0].path.subpaths[0].closed); +} + +#[test] +fn a_thin_ring_traces_as_a_closed_centre_line() { + let img = Raster::from_fn(120, 120, |x, y| { + let d = (x as f64 + 0.5 - 60.0).hypot(y as f64 + 0.5 - 60.0); + if (d - 40.0).abs() <= 1.5 { BLACK } else { WHITE } + }); + let res = trace(&img, &strokes(true)); + let lines = stroked(&res); + assert_eq!(lines.len(), 1, "one ring"); + let sp = lines[0].1; + assert!(sp.closed); + let len = PathData::single(sp.clone()).length(); + let want = std::f64::consts::TAU * 40.0; + assert!((len - want).abs() / want < 0.05, "length {len} vs {want}"); +} + +#[test] +fn wide_areas_stay_filled_and_lines_cross_at_junctions() { + // A disc (r = 20) and, apart from it, a plus sign of 3 px wide bars. + let img = Raster::from_fn(200, 100, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let disc = (px - 50.0).hypot(py - 50.0) <= 20.0; + let plus = ((px - 150.0).abs() <= 1.5 && (20.0..=80.0).contains(&py)) || ((py - 50.0).abs() <= 1.5 && (120.0..=180.0).contains(&px)); + if disc || plus { BLACK } else { WHITE } + }); + let res = trace(&img, &strokes(true)); + let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect(); + assert_eq!(fills.len(), 1, "the disc"); + let a = net_area(&fills[0].path); + assert!((a - std::f64::consts::PI * 400.0).abs() < 60.0, "disc area {a}"); + let lines = stroked(&res); + assert!((2..=4).contains(&lines.len()), "{} centre lines", lines.len()); + // The centre lines stop about half a width short of each end of the bars (their round caps + // cover it). + let total: f64 = lines.iter().map(|(_, s)| PathData::single((*s).clone()).length()).sum(); + assert!((106.0..=120.0).contains(&total), "both bars' length: {total}"); + let ends: Vec = lines.iter().flat_map(|(_, s)| [s.anchors[0].p, s.anchors[s.anchors.len() - 1].p]).collect(); + for want in [Point::new(150.0, 20.0), Point::new(150.0, 80.0), Point::new(120.0, 50.0), Point::new(180.0, 50.0)] { + assert!(ends.iter().any(|e| e.distance(want) < 4.0), "a line ends near {want:?}: {ends:?}"); + } + // Strokes only: the disc is outlined. + let res = trace(&img, &strokes(false)); + assert!(res.paths.iter().all(|p| p.stroke.is_some())); + assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline"); +} + +#[test] +fn create_settings_parse_with_defaults() { + let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap(); + assert!(p.fills && p.strokes && p.stroke_width == 4.0); + let d = TraceParams::default(); + assert!(d.fills && !d.strokes && d.stroke_width == 10.0); +} + +#[test] +fn create_strokes_survives_noise_and_tiny_images() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let noise: Vec = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect(); + let noise = Raster::new(120, 120, noise); + for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] { + for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] { + for noise in [0, 1, 4] { + let p = TraceParams { strokes: true, fills, stroke_width: width, noise, ..TraceParams::default() }; + for t in trace(&img, &p).paths { + assert!(t.stroke.is_none_or(|w| w.is_finite() && w >= 1.0), "{:?}", t.stroke); + } + } + } + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6ee0e1c..d90b01a7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1566,6 +1566,9 @@ No text selected No hay texto seleccionado No Thumbnails Sin miniaturas Noise Ruido + Create: Crear: + Fills Rellenos + Strokes Trazos Nonbreaking Hyphen Guion de no separación Nonbreaking Space Espacio de no separación None Ninguno diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 135d3eec..0a7bc674 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1563,6 +1563,9 @@ No text selected Aucun texte sélectionné No Thumbnails Sans vignettes Noise Bruit + Create: Créer : + Fills Fonds + Strokes Contours Nonbreaking Hyphen Trait d’union insécable Nonbreaking Space Espace insécable None Aucun diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 39f0e052..812ffa32 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1566,6 +1566,9 @@ No text selected Nessun testo selezionato No Thumbnails Nessuna miniatura Noise Disturbo + Create: Crea: + Fills Riempimenti + Strokes Tracce Nonbreaking Hyphen Trattino indivisibile Nonbreaking Space Spazio indivisibile None Nessuno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 29667b31..b91217af 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1696,6 +1696,9 @@ No symbols in this document このドキュメントにシンボルはありません No text selected テキストが選択されていません Noise ノイズ + Create: 作成: + Fills 塗り + Strokes 線 None なし None. なし。 Normal 通常 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 607911a1..bce16790 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1562,6 +1562,9 @@ No text selected Текст не выделен No Thumbnails Без миниатюр Noise Шум + Create: Создать: + Fills Заливки + Strokes Обводки Nonbreaking Hyphen Неразрывный дефис Nonbreaking Space Неразрывный пробел None Нет diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a2fe75d6..604dd1df 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1541,6 +1541,9 @@ No text selected 未选择文字 No Thumbnails 无缩略图 Noise 杂色 + Create: 创建: + Fills 填色 + Strokes 描边 Nonbreaking Hyphen 不断行连字号 Nonbreaking Space 不断行空格 None 无 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b23c0fd5..28174855 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1583,6 +1583,9 @@ No text selected 未選取文字 No Thumbnails 無縮圖 Noise 雜訊 + Create: 建立: + Fills 填色 + Strokes 筆畫 Nonbreaking Hyphen 不斷行連字號 Nonbreaking Space 不斷行空格 None 無 diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index bda45ab0..12234d1a 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -1,4 +1,5 @@ -//! Image Trace panel: preset, mode, threshold or colour count, and the advanced fidelity options. +//! Image Trace panel: preset, mode, threshold or colour count, and the advanced options (fidelity, +//! method, Create: Fills / Strokes with the widest line stroked). //! With an Image Trace object selected, changing a setting re-traces it (one undo step per change; //! sliders apply when released). With an image selected, Trace makes a new Image Trace object. @@ -207,6 +208,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); + // Create: Fills and/or Strokes (one of them stays on), and the widest line stroked. + let create = |p: &Value, key: &str| p[key].as_bool().unwrap_or(key == "fills"); + let strokes = create(&st.params, "strokes"); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Create:")); + for (key, other, label) in [("fills", "strokes", tl!("Fills")), ("strokes", "fills", tl!("Strokes"))] { + let on = create(&st.params, key); + if widgets::check(ui, label, on, !on || create(&st.params, other)) { + st.params[key] = json!(!on); + st.preset = "Custom".into(); + retrace = true; + } + } + }); + let mut v = st.params["strokeWidth"].as_f64().unwrap_or(10.0); + let (changed, release) = ui.add_enabled_ui(strokes, |ui| slider(ui, tl!("Stroke"), &mut v, 1.0..=100.0, " px")).inner; + if changed { + st.params["strokeWidth"] = json!(v.round()); + st.preset = "Custom".into(); + } + retrace |= release; for (key, label) in [("snapCurvesToLines", tl!("Snap Curves To Lines")), ("ignoreWhite", tl!("Ignore White"))] { let on = st.params[key].as_bool().unwrap_or(false); if widgets::check(ui, label, on, true) { @@ -258,6 +280,57 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + + /// One headless frame of the panel (and its menu) with `events`: the texts drawn, with where. + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) + } + + /// Click the panel's `label` (the first one drawn). + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let texts = frame(app, ctx, vec![]); + let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}")); + let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![Event::PointerMoved(at), press(true)]); + frame(app, ctx, vec![press(false)]); + } + + fn traced_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false})).unwrap(); + app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); + app + } + + #[test] + fn create_strokes_traces_again_and_keeps_one_kind_on() { + let mut app = traced_app(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let create = |app: &VectorcraftApp| target(app).2.map(|t| (t["params"]["fills"].clone(), t["params"]["strokes"].clone())); + let texts = frame(&mut app, &ctx, vec![]); + for s in ["Create:", "Fills", "Strokes", "Stroke", "10"] { + assert!(texts.iter().any(|(t, _)| t == s), "{s}: {texts:?}"); + } + // Fills alone can't be turned off. + click(&mut app, &ctx, "Fills"); + assert_eq!(create(&app), Some((json!(true), json!(false)))); + click(&mut app, &ctx, "Strokes"); + assert_eq!(create(&app), Some((json!(true), json!(true))), "traced again with strokes"); + click(&mut app, &ctx, "Fills"); + assert_eq!(create(&app), Some((json!(false), json!(true)))); + click(&mut app, &ctx, "Strokes"); + assert_eq!(create(&app), Some((json!(false), json!(true))), "Strokes alone stays on"); + } #[test] fn panel_and_menu_draw_headless() { @@ -266,6 +339,7 @@ mod tests { app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); for _ in 0..2 { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { show(&mut app, ui); menu(&mut app, ui); diff --git a/crates/ui-egui/src/tests_removeanchors.rs b/crates/ui-egui/src/tests_removeanchors.rs index 0baee6c3..80a7dcc3 100644 --- a/crates/ui-egui/src/tests_removeanchors.rs +++ b/crates/ui-egui/src/tests_removeanchors.rs @@ -37,7 +37,7 @@ fn labels(items: &[Item]) -> Vec<&'static str> { .collect() } -fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { +pub(crate) fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> { fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) { match s { Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))), From dc39146d0dc845a76261457e441969cbd0cf6f94 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 10:18:47 +0700 Subject: [PATCH 69/74] Fix #651: a dual-GPU Mac falls back to its other GPU Metal reports no PCI ids, so both GPUs of a dual-GPU Mac had the adapter key Metal:0000:0000. When the integrated GPU failed at start-up, the restart left out both and the app quit, although the discrete GPU works. An adapter without ids is now keyed by its name, so the restart leaves out only the one that failed. The failure itself was wgpu's indirect-call validation compute shader, which OCLP-patched Metal on an Iris Pro can't compile. Nothing in VectorCraft or egui draws indirectly, so that validation is off (WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/vectorcraft/src/gpu.rs | 45 +++++++++++++++++++++++++++++------- apps/vectorcraft/src/main.rs | 5 ++++ docs/development.md | 3 ++- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 185da55e..e03763ef 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index ba504a34..a4b126e8 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -22,8 +22,9 @@ use std::sync::{Arc, OnceLock}; use eframe::egui_wgpu::NativeAdapterSelectorMethod; use eframe::wgpu::{self, Backend, DeviceType, PowerPreference}; -/// The adapters a restart leaves out, those the window failed on, as comma-separated -/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again. +/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s +/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts +/// again. pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP"; /// wgpu's variable for choosing an adapter by (part of) its name, any case. @@ -60,7 +61,7 @@ pub fn power_preference(pref: Option<&str>, env: Option) -> Pow /// One adapter, as [`adapter_order`] sees it. #[derive(Clone, Debug)] pub struct Candidate { - /// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]). + /// Identifies the adapter across a restart ([`key`]). pub key: String, pub name: String, pub backend: Backend, @@ -73,7 +74,7 @@ impl Candidate { fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self { let info = adapter.get_info(); Self { - key: key(info.backend, info.vendor, info.device), + key: key(info.backend, info.vendor, info.device, &info.name), presents: surface.is_none_or(|s| adapter.is_surface_supported(s)), name: info.name, backend: info.backend, @@ -83,8 +84,15 @@ impl Candidate { } /// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them). -fn key(backend: Backend, vendor: u32, device: u32) -> String { - format!("{backend:?}:{vendor:04x}:{device:04x}") +/// Metal reports no ids (`0000:0000` for every GPU), so an adapter without them is `backend:name` +/// (`Metal:Intel Iris Pro Graphics`): otherwise leaving out the one that failed would leave out +/// every GPU of a dual-GPU Mac (#651). Commas, which separate [`SKIP_ENV`]'s keys, become spaces. +fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String { + if vendor == 0 && device == 0 { + format!("{backend:?}:{}", name.replace(',', " ").trim()) + } else { + format!("{backend:?}:{vendor:04x}:{device:04x}") + } } /// The order to try `candidates` in (their indices): only those that present to the window and @@ -307,6 +315,27 @@ mod tests { /// A restart leaves out the adapter the window failed on, so Power Saving on the machine of /// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try. + /// A dual-GPU Mac reports both GPUs under Metal with no PCI ids (#651): their keys still + /// differ, so when the integrated GPU fails, the restart renders on the discrete one. + #[test] + fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + 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, &[]); + 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"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); + } + #[test] fn a_restart_tries_the_next_adapter() { let c = issue_502(); @@ -433,8 +462,8 @@ mod tests { fn skipped_adapters_parse_from_a_comma_separated_list() { assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); assert!(parse_skip("").is_empty()); - assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e"); - assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04"); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); } #[test] diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e6a3b9a1..399c70fd 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -423,6 +423,11 @@ fn main() -> std::process::ExitCode { // 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())); + // 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. + create.instance_descriptor.flags = + (eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); } options }; diff --git a/docs/development.md b/docs/development.md index b303e896..9bf130a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,11 +43,12 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU ( - **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). - 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. 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. +- **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: - `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order). - `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log. - `WGPU_BACKEND`: the backends to use, such as `vulkan`, `dx12`, `metal` or `gl`. + - `WGPU_VALIDATION_INDIRECT_CALL=1`: turns wgpu's check of indirect draw arguments back on. VectorCraft draws nothing indirectly and turns it off, since the compute shader it needs fails to compile on some drivers (#651). - On Linux with Mesa, `MESA_VK_DEVICE_SELECT=10de:2f04!` (vendor:device as `vulkaninfo --summary` or `lspci -nn` show it) leaves only that GPU to Vulkan, and `DRI_PRIME=1` picks the other GPU. - Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup. From 56c975465c68041c339a3ecdee7ee5c4b8df8f9a Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:12:23 -0700 Subject: [PATCH 70/74] M7: inline art follows vertically aligned lines align_vertically moved lines and glyphs but not TextLayout::inlines, so inline symbols in a centred or bottom-aligned area stayed where top alignment put them. The reflow path for non-rectangular frames also kept the previous pass's inlines, drawing each symbol twice. --- crates/text/src/layout.rs | 18 ++++++++++++++++-- crates/text/src/tests_inline.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 98e8809b..60c2e96d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -595,6 +595,15 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re g.origin.y += d; } } + // Inline art moves with its glyph's line. + for i in &mut cx.out.inlines { + let d = cx.out.glyphs.get(i.glyph).and_then(|g| offs.get(g.line)).copied().unwrap_or(0.0); + if d != 0.0 { + let m = Affine::translate((0.0, d)); + i.xf = m * i.xf; + i.bounds = m.transform_rect_bbox(i.bounds); + } + } return; } let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end); @@ -624,12 +633,17 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re return; } let before = laid_out(&cx.out); - let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false)); + let prev = ( + std::mem::take(&mut cx.out.glyphs), + std::mem::take(&mut cx.out.lines), + std::mem::take(&mut cx.out.inlines), + std::mem::replace(&mut cx.out.overflow, false), + ); flow(cx, paras, t, Some(regions)); if laid_out(&cx.out) < before { // Text no longer fits (lines got narrower, or flow around a wrap object): undo the // pass and try a smaller step. - (cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev; + (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.overflow) = prev; for (r, (shift, gap)) in regions.iter_mut().zip(saved) { r.shift = shift; r.gap = gap; diff --git a/crates/text/src/tests_inline.rs b/crates/text/src/tests_inline.rs index 6a435ade..49555538 100644 --- a/crates/text/src/tests_inline.rs +++ b/crates/text/src/tests_inline.rs @@ -296,3 +296,33 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() { let (tall0, tall1) = lines(3.0); assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); } + +#[test] +fn inline_art_moves_with_vertically_aligned_lines() { + use vectorcraft_doc::text::{AreaOptions, VerticalAlign}; + // A rectangle (lines shift) and a triangle (text flows again lower down). + let rect = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1); + let mut tri = kurbo::BezPath::new(); + tri.move_to((0.0, 0.0)); + tri.line_to((800.0, 0.0)); + tri.line_to((400.0, 400.0)); + tri.close_path(); + for frame in [rect, tri] { + for align in [VerticalAlign::Center, VerticalAlign::Bottom] { + let mut t = text("A", art(1.0), "B", 20.0); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.area = AreaOptions { vertical_align: align, ..AreaOptions::default() }; + let l = layout(db(), &t); + assert_eq!(l.inlines.len(), 1, "{align:?}"); + let g = inline_glyph(&l); + let line = &l.lines[g.line]; + assert!(line.baseline > 100.0, "{align:?}: not aligned, baseline {}", line.baseline); + // Still centred on the cap height above its (moved) baseline. + let face = db().face("Source Sans 3", "Regular").unwrap(); + let cap = face.cap_height * 20.0 / face.upem; + let b = l.inlines[0].bounds; + assert!((b.center().y - (line.baseline - cap * 0.5)).abs() < 1e-6, "{align:?}: {b:?} baseline {}", line.baseline); + assert!((b.x0 - g.origin.x).abs() < 1e-6, "{align:?}: {b:?}"); + } + } +} From abdc26d8d1c69ef6a3373332d98fb6414702d591 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 71/74] M7: Shrink Text to Fit scales inline art's baseline shift The fit scaled each run's size, leading and baseline shift but not an inline graphic's own shift (points), so shrunk symbols sat off the cap-height centre. --- crates/text/src/layout.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 60c2e96d..ba73c210 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -206,14 +206,17 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Some(best.unwrap_or_else(|| at(lo))) } -/// `t` with every run's size, explicit leading and baseline shift scaled by `f` (auto leading -/// follows the size; paragraph spacing stays). +/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own +/// shift included (auto leading and the art's size follow the size; paragraph spacing stays). fn scaled(t: &TextObject, f: f64) -> TextObject { let mut s = t.clone(); for r in &mut s.runs { r.style.size *= f; r.style.leading = r.style.leading.map(|l| l * f); r.style.baseline_shift *= f; + if let Some(a) = &mut r.inline { + a.baseline_shift *= f; + } } s } From 53e39884fcbf758c3716312bf05301f3edc98a7c Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 72/74] M7: Auto Size measures vertically aligned area type from the top auto_height lays the text out in a very tall probe frame; with Center, Bottom or Justify the lines moved down inside it and the frame grew to tens of thousands of points. --- crates/engine/src/cmd/typecmd.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 392f1d80..f51db4be 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -147,6 +147,8 @@ fn auto_height(t: &mut TextObject) -> bool { let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() }; with_size(&mut probe, col_w, tall); probe.area.columns = 1; + // Measured from the top: aligned in the tall probe the lines would sit far below. + probe.area.vertical_align = vectorcraft_doc::VerticalAlign::Top; let lay = vectorcraft_text::layout(db, &probe); let inset = t.area.inset.max(0.0); let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false }; From 992f6a0a4dd6e2f645515220a2a2abd91b4e2b70 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 73/74] M7: tests for the text engine features combined Layout (vectorcraft-text tests_combos): every pair of ligatures under tracking, the paragraph composer, vertical alignment, Shrink Text to Fit, per-paragraph attributes and inline graphics, and all six at once. Commands (vectorcraft-engine tests_textcombos): Auto Size and Shrink Text with inline art, paragraphs, composer and alignment; editing and undo across them; a save round trip with everything on. --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_textcombos.rs | 310 ++++++++++++++ crates/text/src/lib.rs | 2 + crates/text/src/tests_combos.rs | 574 ++++++++++++++++++++++++++ 4 files changed, 888 insertions(+) create mode 100644 crates/engine/src/tests_textcombos.rs create mode 100644 crates/text/src/tests_combos.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bdc9063c..cc8184df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1586,6 +1586,8 @@ mod tests_swatchlib; #[cfg(test)] mod tests_targeting; #[cfg(test)] +mod tests_textcombos; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_textimport; diff --git a/crates/engine/src/tests_textcombos.rs b/crates/engine/src/tests_textcombos.rs new file mode 100644 index 00000000..5b3c89b4 --- /dev/null +++ b/crates/engine/src/tests_textcombos.rs @@ -0,0 +1,310 @@ +//! The text engine's newer features combined through commands: Auto Size and Shrink Text to Fit +//! with inline graphics, per-paragraph attributes, the paragraph composer and vertical +//! alignment; editing and undo across them; and a save round trip with all of them on. +//! (`vectorcraft_text`'s `tests_combos` covers the layout side.) + +use serde_json::{Value, json}; +use vectorcraft_doc::text::INLINE_CHAR; +use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign}; +use vectorcraft_geom::Rect; + +use super::*; + +const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// A symbol `name`: a circle `d` points across, its instance deleted. +fn circle_symbol(s: &mut Session, name: &str, d: f64) { + s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap(); + s.execute("symbol.new", &json!({"name": name})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); +} + +/// Area type at (40, 40), `w` × `h`, size 12, with `extra` area options; selected. +fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId { + let mut a = json!({"width": w, "height": h}); + if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) { + a.extend(e.clone()); + } + let id = NodeId(s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn frame(s: &Session, id: NodeId) -> Rect { + match text(s, id).kind { + TextKind::Area { frame } => frame.bounds().unwrap(), + k => panic!("not area type: {k:?}"), + } +} + +fn lay(t: &TextObject) -> vectorcraft_text::TextLayout { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) +} + +/// Auto Size's frame is tight: the text fits, and a point less overflows. +fn assert_tight(s: &Session, id: NodeId, what: &str) { + let t = text(s, id); + assert!(!lay(&t).overflow, "{what}: the frame holds the text"); + let b = frame(s, id); + let mut less = t.clone(); + less.area.fit = AreaFit::None; + less.kind = TextKind::Area { + frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(b.x0, b.y0, b.x1, b.y1 - 1.0), 0.1)), + }; + assert!(lay(&less).overflow, "{what}: a point less overflows ({b:?})"); +} + +fn undo(s: &mut Session) { + s.execute("edit.undo", &json!({})).unwrap(); +} + +fn redo(s: &mut Session) { + s.execute("edit.redo", &json!({})).unwrap(); +} + +fn query(s: &mut Session) -> Value { + s.execute("text.areaOptions", &json!({})).unwrap() +} + +#[test] +fn auto_height_x_inline_graphics() { + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 200.0, 300.0, "Tap: add one mana.", json!({"fit": "autoHeight"})); + let one = frame(&s, id); + assert_tight(&s, id, "one line"); + // Art taller than the line makes the line, and so the frame, taller: in the same undo step. + let n = s.doc().unwrap().history.undo.len(); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 3})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let tall = frame(&s, id); + assert!(tall.height() > one.height() + 5.0, "{one:?} → {tall:?}"); + assert_tight(&s, id, "tall art"); + // Enough art to wrap: another line. + for _ in 0..12 { + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1})).unwrap(); + } + assert!(lay(&text(&s, id)).lines.len() > 1); + assert_tight(&s, id, "wrapped art"); + // Deleting the art shrinks it back; undo restores each step exactly. + let wrapped = frame(&s, id); + let all = text(&s, id).plain_text().rfind(INLINE_CHAR).unwrap() + INLINE_CHAR.len_utf8(); + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": all})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "Tap: add one mana."); + assert_eq!(frame(&s, id), one); + undo(&mut s); + assert_eq!(frame(&s, id), wrapped); + for _ in 0..13 { + undo(&mut s); + } + assert_eq!(frame(&s, id), one); + redo(&mut s); + assert_eq!(frame(&s, id), tall); +} + +#[test] +fn auto_height_x_paragraph_attrs() { + let mut s = session(); + let id = area(&mut s, 200.0, 300.0, "One\nTwo", json!({"fit": "autoHeight"})); + let h0 = frame(&s, id).height(); + // Space before on the second paragraph only: the frame grows by exactly that. + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 30, "start": 4, "end": 4})).unwrap(); + let t = text(&s, id); + assert_eq!((t.para_at(0).space_before, t.para_at(1).space_before), (0.0, 30.0)); + let h1 = frame(&s, id).height(); + assert!((h1 - h0 - 30.0).abs() < 1e-6, "{h0} → {h1}"); + assert_tight(&s, id, "space before"); + // Return in the spaced paragraph: the new one inherits the spacing, the frame follows. + s.execute("text.editRange", &json!({"id": id.0, "start": 7, "end": 7, "insert": "\nThree"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::>(), [0.0, 30.0, 30.0]); + assert!(frame(&s, id).height() > h1 + 30.0); + assert_tight(&s, id, "three paragraphs"); + // Merging paragraphs: the first one's attributes win, the frame shrinks. + s.execute("text.editRange", &json!({"id": id.0, "start": 3, "end": 4})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::>(), [0.0, 30.0]); + assert_tight(&s, id, "merged"); + undo(&mut s); + undo(&mut s); + assert!((frame(&s, id).height() - h1).abs() < 1e-9); + undo(&mut s); + assert!((frame(&s, id).height() - h0).abs() < 1e-9); + assert!(text(&s, id).paras.is_empty(), "back to one style for all"); +} + +#[test] +fn auto_height_x_composer() { + // Switching the composer of one paragraph rebreaks it; Auto Size follows either way. + let mut s = session(); + let story = format!("{STORY} {STORY}\n{STORY}"); + let id = area(&mut s, 130.0, 100.0, &story, json!({"fit": "autoHeight"})); + assert_tight(&s, id, "every-line"); + for composer in ["singleLine", "everyLine", "singleLine"] { + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": composer, "start": 0, "end": 0})).unwrap(); + let t = text(&s, id); + assert_eq!(t.para_at(1).composer, Composer::EveryLine, "the second paragraph keeps its own"); + assert_tight(&s, id, composer); + } + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll"})).unwrap(); + assert!(text(&s, id).paragraph_styles().iter().all(|p| p.composer == Composer::SingleLine && p.justify == Justify::JustifyAll)); + assert_tight(&s, id, "justified"); +} + +#[test] +fn auto_height_x_vertical_align() { + // An Auto Size frame has no space to spare: every alignment sets the lines as Top does, and + // the frame is as tall as with Top. + let mut s = session(); + let id = area(&mut s, 160.0, 100.0, STORY, json!({"fit": "autoHeight"})); + let top_frame = frame(&s, id); + let top = lay(&text(&s, id)); + for a in ["center", "bottom", "justify"] { + s.execute("text.areaOptions", &json!({"verticalAlign": a})).unwrap(); + assert_eq!(frame(&s, id), top_frame, "{a}"); + let l = lay(&text(&s, id)); + for (li, l0) in l.lines.iter().zip(&top.lines) { + assert!((li.baseline - l0.baseline).abs() < 1e-6, "{a}: {} vs {}", li.baseline, l0.baseline); + } + assert_tight(&s, id, a); + } + // Editing with the alignment on keeps the frame tight. + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": format!("{STORY} ")})).unwrap(); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Justify); + assert_tight(&s, id, "longer, justified"); +} + +#[test] +fn shrink_text_x_inline_graphics_and_paragraphs() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 8.0); + let id = area(&mut s, 160.0, 60.0, "Short rules.\nFlavor.", json!({"fit": "shrinkText", "fitMinPercent": 20})); + assert_eq!(query(&mut s)["fitScale"], 1.0); + // Inline art and per-paragraph spacing push it over; shrinking makes it fit. + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 12, "start": 13, "end": 13})).unwrap(); + for i in 0..10 { + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1.0 + i as f64 * 0.2})).unwrap(); + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Add "})).unwrap(); + } + let q = query(&mut s); + assert_eq!(q["overflow"], false, "{q}"); + let f = q["fitScale"].as_f64().unwrap(); + assert!((0.2..1.0).contains(&f), "{f}"); + let t = text(&s, id); + assert_eq!(t.paragraph_count(), 2); + assert_eq!(t.para_at(1).space_before, 12.0, "inserting in the first paragraph keeps the second's attributes"); + assert_eq!(t.runs[0].style.size, 12.0, "the stored size stays"); + let l = lay(&t); + assert_eq!(l.inlines.len(), 10); + assert!(l.inlines.iter().all(|i| i.bounds.height() < 12.0 * 2.8 * f + 1e-6)); + // Undo walks the fit back up. + for _ in 0..20 { + undo(&mut s); + } + assert_eq!(query(&mut s)["fitScale"], 1.0); +} + +#[test] +fn paragraph_attrs_x_inline_editing() { + // An inline graphic opening a paragraph belongs to it: inserting it keeps the paragraph's + // attributes, deleting it with the paragraph break merges into the paragraph before. + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 300.0, 200.0, "Rules\nFlavor", json!({})); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "center", "start": 6, "end": 6})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine", "start": 6, "end": 6})).unwrap(); + s.execute("text.insertInline", &json!({"id": id.0, "at": 6, "symbol": "Tap"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor")); + assert_eq!((t.para_at(0).justify, t.para_at(1).justify), (Justify::Auto, Justify::Center)); + assert_eq!(t.para_at(1).composer, Composer::SingleLine); + // The art's line is centred. + let l = lay(&t); + let g = &l.glyphs[l.inlines[0].glyph]; + let line = &l.lines[g.line]; + assert!(((line.x0 + line.x1) * 0.5 - 150.0).abs() < 1e-6, "centred in the 300 pt frame: {line:?}"); + assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6); + // Return just before the art: the new paragraph (holding the art) continues the centred one. + s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 6, "insert": "Text\n"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.justify).collect::>(), [Justify::Auto, Justify::Center, Justify::Center]); + undo(&mut s); + // Delete the break and the art together: one paragraph, the first one's attributes. + let end = 6 + INLINE_CHAR.len_utf8(); + s.execute("text.editRange", &json!({"id": id.0, "start": 5, "end": end})).unwrap(); + let t = text(&s, id); + assert_eq!(t.plain_text(), "RulesFlavor"); + assert!(t.paras.is_empty() && t.para.justify == Justify::Auto && t.para.composer == Composer::EveryLine); + assert!(t.runs.iter().all(|r| r.inline.is_none())); + undo(&mut s); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor")); + assert_eq!(t.para_at(1).justify, Justify::Center); + assert_eq!(t.runs.iter().filter(|r| r.inline.is_some()).count(), 1); +} + +#[test] +fn vertical_align_x_inline_graphics_in_the_document() { + // The art the renderer draws (the layout's inline placements) moves with centred lines. + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 300.0, 200.0, "Add one mana.", json!({})); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap"})).unwrap(); + let top = lay(&text(&s, id)); + s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap(); + let l = lay(&text(&s, id)); + let d = l.lines[0].baseline - top.lines[0].baseline; + assert!(d > 50.0, "{d}"); + assert!((l.inlines[0].bounds.y0 - top.inlines[0].bounds.y0 - d).abs() < 1e-6, "{:?} vs {:?}", l.inlines[0].bounds, top.inlines[0].bounds); + // The cached bounds (hit testing, selection) include the moved art. + let cached = text(&s, id).cached_bounds.unwrap(); + assert!(cached.contains(l.inlines[0].bounds.center()), "{cached:?}"); +} + +#[test] +fn every_feature_saves_and_reopens() { + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area( + &mut s, + 220.0, + 70.0, + &format!(": Add one mana of any color. {STORY}\n{STORY}"), + json!({"verticalAlign": "center", "fit": "shrinkText", "fitMinPercent": 30}), + ); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 0.8, "shift": 0.5})).unwrap(); + let flavor = text(&s, id).plain_text().find('\n').unwrap() + 1; + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 8, "composer": "singleLine", "start": flavor, "end": flavor})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll", "start": flavor, "end": flavor})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "tracking": 20})).unwrap(); + let t = text(&s, id); + let l = lay(&t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_eq!(l.inlines.len(), 1); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + let NodeKind::Text(t2) = &back.node(id).unwrap().kind else { panic!("not text") }; + let mut t2 = (**t2).clone(); + // Resolved art bounds and layout caches aren't saved: resolve them as opening does. + back.resolve_text_inline(&mut t2); + assert_eq!(t2.runs, t.runs); + assert_eq!(t2.paragraph_styles(), t.paragraph_styles()); + assert_eq!(t2.area, t.area); + let l2 = lay(&t2); + assert_eq!((l2.fit_scale, l2.lines.len()), (l.fit_scale, l.lines.len())); + assert_eq!(l2.inlines.iter().map(|i| i.bounds).collect::>(), l.inlines.iter().map(|i| i.bounds).collect::>()); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 33c1360f..cf5ed57c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -502,6 +502,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_combos; +#[cfg(test)] mod tests_embed; #[cfg(test)] mod tests_fit; diff --git a/crates/text/src/tests_combos.rs b/crates/text/src/tests_combos.rs new file mode 100644 index 00000000..0453058b --- /dev/null +++ b/crates/text/src/tests_combos.rs @@ -0,0 +1,574 @@ +//! The text engine's newer features combined, two at a time and all together: ligatures under +//! tracking, the paragraph composer, area type vertical alignment, Shrink Text to Fit, +//! per-paragraph attributes and inline graphics. Each feature has its own tests; these check that +//! one doesn't undo or skip another (as vertical alignment once left inline art behind). + +use super::*; +use kurbo::Shape; +use vectorcraft_doc::{AreaFit, CharStyle, Composer, InlineArt, Justify, TextKind, TextRun, VerticalAlign}; +use vectorcraft_geom::PathData; + +const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \ +The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing."; + +/// Words full of `fi` (Source Serif 4 ligates it). +const FI: &str = "fine fish finish first in fifty fields of fireflies, a fitting finale for the final fiddler"; + +/// Two lines that single-line and every-line composers break differently (see +/// `every_line_composer_balances_ragged_lines`). +const RAG: &str = "Destroy all creatures with toughness X or less."; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn sans(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +fn serif(size: f64, tracking: f64) -> CharStyle { + CharStyle { font_family: "Source Serif 4".into(), size, tracking, ..CharStyle::default() } +} + +fn run(text: &str, style: CharStyle) -> TextRun { + TextRun::new(text, style) +} + +/// A square symbol, `scale` × the run's size tall, raised `shift` points. +fn art(scale: f64, shift: f64) -> InlineArt { + InlineArt { scale, baseline_shift: shift, bounds: Some(Rect::new(-5.0, -5.0, 5.0, 5.0)), ..InlineArt::new("sym") } +} + +fn sym(scale: f64, shift: f64, style: CharStyle) -> TextRun { + TextRun::inline(art(scale, shift), style) +} + +/// Area type in `frame` holding `runs`. +fn area(runs: Vec, frame: Rect) -> TextObject { + let mut t = TextObject::point(Point::ZERO, "", sans(12.0)); + t.runs = runs; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t +} + +/// `t` as Shrink Text to Fit lays it out at factor `f`, without fitting: sizes, explicit leading +/// and baseline shifts scaled (inline art's own shift too), paragraph spacing kept. +fn scaled_by(t: &TextObject, f: f64) -> TextObject { + let mut s = t.clone(); + s.area.fit = AreaFit::None; + for r in &mut s.runs { + r.style.size *= f; + r.style.leading = r.style.leading.map(|l| l * f); + r.style.baseline_shift *= f; + if let Some(a) = &mut r.inline { + a.baseline_shift *= f; + } + } + s +} + +fn line_texts(t: &TextObject, l: &TextLayout) -> Vec { + let text = t.plain_text(); + l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect() +} + +/// Ligature clusters: glyphs standing for more than one character. +fn ligatures(l: &TextLayout) -> usize { + l.glyphs.iter().filter(|g| g.len > 1 && !g.outline.elements().is_empty()).count() +} + +/// Every inline graphic drawn once, where its glyph is: left edge on the glyph's origin, the +/// scaled height, centred half the (scaled) cap height above the glyph's baseline plus its own +/// (scaled) shift. +fn assert_inlines_on_their_glyphs(t: &TextObject, l: &TextLayout, what: &str) { + let want = t.runs.iter().filter(|r| r.inline.is_some()).count(); + assert_eq!(l.inlines.len(), want, "{what}: one placement per inline run"); + let mut glyphs: Vec = l.inlines.iter().map(|i| i.glyph).collect(); + glyphs.dedup(); + assert_eq!(glyphs.len(), want, "{what}: no inline drawn twice"); + for i in &l.inlines { + let g = &l.glyphs[i.glyph]; + assert_eq!(g.run, i.run, "{what}"); + let r = &t.runs[i.run]; + let a = r.inline.as_ref().unwrap(); + let size = r.style.size * l.fit_scale; + let face = db().face(&r.style.font_family, &r.style.font_style).unwrap(); + let cap = face.cap_height * size / face.upem; + let b = i.bounds; + assert!((b.height() - a.scale * size).abs() < 1e-6, "{what}: height {} vs {}", b.height(), a.scale * size); + assert!((b.x0 - g.origin.x).abs() < 1e-6, "{what}: {b:?} vs origin {:?}", g.origin); + let centre = g.origin.y - cap * 0.5 - a.baseline_shift * l.fit_scale; + assert!((b.center().y - centre).abs() < 1e-6, "{what}: centre {} vs {centre} (line {})", b.center().y, g.line); + assert!(l.lines[g.line].glyph_start <= i.glyph && i.glyph < l.lines[g.line].glyph_end, "{what}"); + assert!(l.bounds.contains(b.center()), "{what}: layout bounds {:?} miss {b:?}", l.bounds); + } +} + +/// Space left below the last line of a one-cell frame. +fn slack(l: &TextLayout, frame: Rect) -> f64 { + let last = l.lines.last().unwrap(); + frame.y1 - (last.baseline + last.descent) +} + +fn baselines(l: &TextLayout) -> Vec { + l.lines.iter().map(|li| li.baseline).collect() +} + +// ---------- ligatures under tracking × … ---------- + +#[test] +fn ligatures_x_composer() { + // Justified (where every-line spreads word space) and ragged, with tracking inside the limits + // (kept) and letterspaced (dropped), whichever composer breaks the lines. + for justify in [Justify::Left, Justify::JustifyLeft] { + for (tracking, kept) in [(0.0, true), (20.0, true), (100.0, false)] { + let mut counts = vec![]; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(&FI.repeat(3), serif(12.0, tracking))], Rect::new(0.0, 0.0, 170.0, 1000.0)); + t.para.justify = justify; + t.para.composer = composer; + let l = layout(db(), &t); + assert!(l.lines.len() > 3, "several lines to compose"); + counts.push(ligatures(&l)); + } + assert_eq!(counts[0], counts[1], "{justify:?} {tracking}: composers shape alike"); + assert_eq!(counts[0] > 0, kept, "{justify:?} {tracking}: {counts:?}"); + } + } +} + +#[test] +fn ligatures_x_vertical_align() { + let frame = Rect::new(0.0, 0.0, 200.0, 400.0); + for tracking in [20.0, 100.0] { + let mut t = area(vec![run(FI, serif(12.0, tracking))], frame); + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + let gids = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.byte)).collect::>(); + assert_eq!(gids(&l), gids(&top), "{tracking} {a:?}: moving lines doesn't reshape them"); + assert!(l.lines[0].baseline > top.lines[0].baseline + 1.0 || a == VerticalAlign::Justify, "{a:?}"); + } + } +} + +#[test] +fn ligatures_x_shrink_text() { + // Tracking is in 1/1000 em, so scaling the size doesn't move it across the limits. + let frame = Rect::new(0.0, 0.0, 150.0, 60.0); + for (tracking, features, kept) in [(40.0, vec![], true), (100.0, vec![], false), (100.0, vec!["liga".to_string()], true)] { + let st = CharStyle { features, ..serif(14.0, tracking) }; + let mut t = area(vec![run(&FI.repeat(2), st)], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{tracking}: {}", l.fit_scale); + assert_eq!(ligatures(&l) > 0, kept, "{tracking}"); + assert_eq!(ligatures(&l), ligatures(&layout(db(), &scaled_by(&t, l.fit_scale))), "{tracking}"); + } +} + +#[test] +fn ligatures_x_paragraph_attrs() { + // Paragraph attributes don't touch shaping: a centred, indented, every-line paragraph keeps + // its ligatures and a letterspaced run in the next paragraph loses them. + let mut t = area(vec![run(&format!("{FI}\n"), serif(12.0, 10.0)), run(FI, serif(12.0, 120.0))], Rect::new(0.0, 0.0, 240.0, 600.0)); + let mut second = t.para.clone(); + second.justify = Justify::Center; + second.left_indent = 20.0; + second.space_before = 12.0; + t.set_paragraph_styles(vec![t.para.clone(), second]); + let l = layout(db(), &t); + let split = FI.len() + 1; + let ligs = |first: bool| l.glyphs.iter().filter(|g| g.len > 1 && (g.byte < split) == first).count(); + assert!(ligs(true) > 0, "tracking 10 keeps them"); + assert_eq!(ligs(false), 0, "tracking 120 drops them"); +} + +#[test] +fn ligatures_x_inline_graphics() { + // An inline graphic between ligated words neither breaks their ligatures nor takes them: its + // own letterspaced run tracks the art without touching its neighbours. + let st = serif(14.0, 0.0); + let runs = vec![run("fine fish ", st.clone()), sym(1.0, 0.0, serif(14.0, 200.0)), run(" first fifty", st.clone())]; + let t = area(runs, Rect::new(0.0, 0.0, 400.0, 100.0)); + let l = layout(db(), &t); + let without = area(vec![run("fine fish ", st.clone()), run(" first fifty", st.clone())], Rect::new(0.0, 0.0, 400.0, 100.0)); + assert_eq!(ligatures(&l), ligatures(&layout(db(), &without)), "the same ligatures as without the art"); + assert!(ligatures(&l) >= 4, "fi in fine, fish, first and fifty"); + assert_inlines_on_their_glyphs(&t, &l, "between ligatures"); + let g = &l.glyphs[l.inlines[0].glyph]; + assert!((g.advance - (14.0 + 200.0 / 1000.0 * 14.0)).abs() < 1e-6, "art width plus its tracking: {}", g.advance); +} + +// ---------- composer × … ---------- + +#[test] +fn composer_x_vertical_align() { + let w = natural_width(RAG, 12.0) * 0.9; + let frame = Rect::new(0.0, 0.0, w, 200.0); + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(RAG, sans(12.0))], frame); + t.para.composer = composer; + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{composer:?} {a:?}: the same breaks"); + let want = match a { + VerticalAlign::Center => slack(&top, frame) * 0.5, + _ => slack(&top, frame), + }; + let moved = l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline; + assert!((moved - want).abs() < 1e-6, "{composer:?} {a:?}: last line moved {moved}, want {want}"); + } + } + // And the two composers still differ there. + let breaks = |c: Composer| { + let mut t = area(vec![run(RAG, sans(12.0))], frame); + t.para.composer = c; + t.area.vertical_align = VerticalAlign::Center; + line_texts(&t, &layout(db(), &t)) + }; + assert_ne!(breaks(Composer::SingleLine), breaks(Composer::EveryLine)); +} + +#[test] +fn composer_x_shrink_text() { + // Each composer shrinks to the largest size at which its own breaks fit. + for justify in [Justify::Left, Justify::JustifyLeft] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(COPY, sans(14.0))], Rect::new(0.0, 0.0, 180.0, 90.0)); + t.para.composer = composer; + t.para.justify = justify; + t.area.fit = AreaFit::ShrinkText { min_percent: 20.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{justify:?} {composer:?}"); + let size = 14.0 * l.fit_scale; + assert!(layout(db(), &scaled_by(&t, (size + 0.1) / 14.0)).overflow, "{justify:?} {composer:?}: maximal at {size}"); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + assert_eq!(line_texts(&t, &same), line_texts(&t, &l), "{justify:?} {composer:?}"); + } + } +} + +#[test] +fn composer_x_paragraph_attrs() { + // Each paragraph breaks with its own composer. + let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0; + let mut t = area(vec![run(&format!("{RAG}\n{RAG}\n{RAG}"), sans(12.0))], Rect::new(0.0, 0.0, w, 400.0)); + let p = |c: Composer| vectorcraft_doc::ParaStyle { composer: c, ..t.para.clone() }; + t.set_paragraph_styles(vec![p(Composer::SingleLine), p(Composer::EveryLine), p(Composer::SingleLine)]); + let l = layout(db(), &t); + let greedy = ["Destroy all creatures with toughness X or", "less."]; + let every = ["Destroy all creatures with toughness X", "or less."]; + let want: Vec<&str> = [greedy, every, greedy].concat(); + assert_eq!(line_texts(&t, &l), want); + // The object-wide override still wins over every paragraph. + let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() }); + assert_eq!(line_texts(&t, &forced), [greedy, greedy, greedy].concat()); +} + +#[test] +fn composer_x_inline_graphics() { + // An inline graphic is a word's worth of box to the composers: each one breaks around it as + // around the letter it replaces, and the art follows its glyph to whichever line it lands on. + let st = sans(12.0); + let runs = |x: TextRun| vec![run("Destroy all creatures with toughness ", st.clone()), x, run(" or less.", st.clone())]; + let with_art = runs(sym(0.7, 0.0, st.clone())); + let mut probe = area(with_art.clone(), Rect::new(0.0, 0.0, 10_000.0, 100.0)); + probe.runs.truncate(2); + probe.runs.push(run(" or", st.clone())); + let w = layout(db(), &probe).lines[0].x1 + 2.0; + let sym_text = INLINE.to_string(); + for (composer, first, second) in [ + (Composer::SingleLine, format!("Destroy all creatures with toughness {sym_text} or"), "less."), + (Composer::EveryLine, format!("Destroy all creatures with toughness {sym_text}"), "or less."), + ] { + let mut t = area(with_art.clone(), Rect::new(0.0, 0.0, w, 200.0)); + t.para.composer = composer; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), [first.as_str(), second], "{composer:?}"); + assert_inlines_on_their_glyphs(&t, &l, &format!("{composer:?}")); + } +} + +const INLINE: char = vectorcraft_doc::text::INLINE_CHAR; + +// ---------- vertical alignment × … ---------- + +#[test] +fn vertical_align_x_shrink_text() { + let frame = Rect::new(0.0, 0.0, 200.0, 100.0); + let mut t = area(vec![run(©.repeat(2), sans(14.0))], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let top = layout(db(), &t); + assert!(!top.overflow && top.fit_scale < 1.0); + for a in [VerticalAlign::Center, VerticalAlign::Bottom] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(l.fit_scale, top.fit_scale, "{a:?}: aligning doesn't change the fit"); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}"); + let want = if a == VerticalAlign::Center { slack(&top, frame) * 0.5 } else { slack(&top, frame) }; + for (b, b0) in baselines(&l).iter().zip(baselines(&top)) { + assert!((b - b0 - want).abs() < 1e-6, "{a:?}: {b} vs {b0} + {want}"); + } + if a == VerticalAlign::Bottom { + assert!(slack(&l, frame).abs() < 1e-6, "the shrunk block sits on the bottom"); + } + } + // Still overflowing at the minimum: aligned like any full frame (the same lines, moved by + // less than a line, inside the frame), at the same scale. + let frame = Rect::new(0.0, 0.0, 120.0, 40.0); + let mut t = area(vec![run(©.repeat(4), sans(14.0))], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 90.0 }; + let top = layout(db(), &t); + assert!(top.overflow && (top.fit_scale - 0.9).abs() < 1e-9); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert!(l.overflow && l.fit_scale == top.fit_scale, "{a:?}"); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}"); + let first = &top.lines[0]; + assert!(l.lines[0].baseline - first.baseline < first.ascent + first.descent, "{a:?}"); + assert!(slack(&l, frame) >= -1e-6, "{a:?}"); + } +} + +#[test] +fn vertical_align_x_paragraph_attrs() { + // Per-paragraph spacing and alignment survive the move: lines keep their x and their + // paragraph gaps, and the block lands where the alignment puts it. + let frame = Rect::new(0.0, 0.0, 260.0, 500.0); + let mut t = area(vec![run(&format!("{COPY}\n{COPY}\n{COPY}"), sans(11.0))], frame); + let mut mid = t.para.clone(); + mid.justify = Justify::Center; + mid.space_before = 18.0; + mid.left_indent = 24.0; + let mut last = t.para.clone(); + last.justify = Justify::Right; + last.space_before = 6.0; + t.set_paragraph_styles(vec![t.para.clone(), mid, last]); + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top)); + let d = l.lines[0].baseline - top.lines[0].baseline; + assert!(d > 1.0, "{a:?}"); + for (li, l0) in l.lines.iter().zip(&top.lines) { + assert!((li.baseline - l0.baseline - d).abs() < 1e-6, "{a:?}: every line moves alike"); + assert!((li.x0 - l0.x0).abs() < 1e-6 && (li.x1 - l0.x1).abs() < 1e-6, "{a:?}: x stays"); + } + for (g, g0) in l.glyphs.iter().zip(&top.glyphs) { + assert!((g.origin.x - g0.origin.x).abs() < 1e-6 && (g.origin.y - g0.origin.y - d).abs() < 1e-6); + } + } + // Justify: spacing is shared out on top of each paragraph's own space before, never less. + t.area.vertical_align = VerticalAlign::Justify; + let l = layout(db(), &t); + assert!(slack(&l, frame).abs() < 1e-6, "justified to the bottom"); + for (pair, pair0) in l.lines.windows(2).zip(top.lines.windows(2)) { + assert!(pair[1].baseline - pair[0].baseline >= pair0[1].baseline - pair0[0].baseline - 1e-6); + } +} + +#[test] +fn vertical_align_x_inline_graphics() { + // Inline art moves with its line however the lines are aligned, in one cell or several, in a + // rectangle or a frame the text flows into again. + let st = sans(12.0); + let mut runs = vec![]; + for i in 0..6 { + runs.push(run(&format!("{} ", ©[i * 20..i * 20 + 20]), st.clone())); + runs.push(sym(1.0 + i as f64 * 0.1, i as f64 - 2.0, st.clone())); + if i == 2 { + runs.push(run("\n", st.clone())); + } + } + let mut tri = BezPath::new(); + tri.move_to((0.0, 0.0)); + tri.line_to((600.0, 0.0)); + tri.line_to((300.0, 400.0)); + tri.close_path(); + for (name, frame, columns) in [ + ("rect", Rect::new(0.0, 0.0, 160.0, 400.0).to_path(0.1), 1), + ("columns", Rect::new(0.0, 0.0, 400.0, 300.0).to_path(0.1), 2), + ("triangle", tri, 1), + ] { + for a in VerticalAlign::ALL { + let mut t = area(runs.clone(), Rect::ZERO); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.area.columns = columns; + t.area.vertical_align = a; + let l = layout(db(), &t); + assert!(!l.overflow, "{name} {a:?}"); + assert_inlines_on_their_glyphs(&t, &l, &format!("{name} {a:?}")); + } + } +} + +// ---------- Shrink Text to Fit × … ---------- + +#[test] +fn shrink_text_x_paragraph_attrs() { + // Shrinking scales the type, not each paragraph's spacing and indents: the fitted layout is + // the unfitted one at the scaled size. + let mut t = area(vec![run(&format!("{COPY}\n{COPY}"), sans(13.0))], Rect::new(0.0, 0.0, 220.0, 160.0)); + let mut second = t.para.clone(); + second.space_before = 14.0; + second.left_indent = 30.0; + second.first_line_indent = 12.0; + second.justify = Justify::JustifyLeft; + second.composer = Composer::SingleLine; + t.set_paragraph_styles(vec![t.para.clone(), second]); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + assert_eq!(baselines(&l), baselines(&same)); + assert_eq!(line_texts(&t, &l), line_texts(&t, &same)); + // The second paragraph's indents are unscaled points. + let split = COPY.len() + 1; + let first_of_second = l.lines.iter().find(|li| li.start == split).unwrap(); + assert!((first_of_second.x0 - 42.0).abs() < 1e-6, "{}", first_of_second.x0); + assert!(l.lines.iter().filter(|li| li.start > split).all(|li| (li.x0 - 30.0).abs() < 1e-6)); + // A paragraph's space before alone can make the text overflow; shrinking still finds a fit. + let mut tall = t.clone(); + tall.edit_paras(Some(1..2), |p| p.space_before = 100.0); + let l = layout(db(), &tall); + assert!(!l.overflow && l.fit_scale < layout(db(), &t).fit_scale); +} + +#[test] +fn shrink_text_x_inline_graphics() { + // Inline art shrinks with the type, its shift included, and stays centred on the cap height. + let st = sans(14.0); + let mut runs = vec![]; + for i in 0..8 { + runs.push(run(©[i * 25..i * 25 + 25], st.clone())); + runs.push(sym(1.2, 1.5, st.clone())); + } + let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 80.0)); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_inlines_on_their_glyphs(&t, &l, "shrunk"); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + for (i, i0) in l.inlines.iter().zip(&same.inlines) { + assert!((i.bounds.y0 - i0.bounds.y0).abs() < 1e-6 && (i.bounds.x0 - i0.bounds.x0).abs() < 1e-6, "{:?} vs {:?}", i.bounds, i0.bounds); + } + // Large art makes lines taller; shrinking still fits it. + for r in &mut t.runs { + if let Some(a) = &mut r.inline { + a.scale = 3.0; + } + } + let big = layout(db(), &t); + assert!(!big.overflow && big.fit_scale < l.fit_scale); + assert_inlines_on_their_glyphs(&t, &big, "shrunk, big art"); +} + +// ---------- paragraph attributes × inline graphics ---------- + +#[test] +fn paragraph_attrs_x_inline_graphics() { + // Inline art in paragraphs of each alignment and indent sits on its glyph, and an inline + // graphic opening a paragraph takes that paragraph's first-line indent. + let st = sans(12.0); + let runs = vec![ + run("Left aligned ", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" text.\n", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" centred with art first\nright ", st.clone()), + sym(1.5, -1.0, st.clone()), + run(" aligned\nfull justified text with ", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" in the middle of a long line that wraps", st.clone()), + ]; + let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 400.0)); + let base = t.para.clone(); + let with = |j: Justify, first: f64| vectorcraft_doc::ParaStyle { justify: j, first_line_indent: first, left_indent: 6.0, ..base.clone() }; + t.set_paragraph_styles(vec![base.clone(), with(Justify::Center, 0.0), with(Justify::Right, 0.0), with(Justify::JustifyLeft, 18.0)]); + let l = layout(db(), &t); + assert_inlines_on_their_glyphs(&t, &l, "paragraphs"); + let opening = &l.glyphs[l.inlines[1].glyph]; + let line = &l.lines[opening.line]; + assert!((opening.origin.x - line.x0).abs() < 1e-6, "the art opens its line"); + assert!(((line.x0 + line.x1) * 0.5 - (6.0 + 200.0) * 0.5).abs() < 1e-6, "the centred paragraph: {line:?}"); + let right = &l.lines[l.glyphs[l.inlines[2].glyph].line]; + assert!((right.x1 - 200.0).abs() < 1e-6, "{right:?}"); + let full = l.lines.iter().find(|li| li.start == t.plain_text().find("full").unwrap()).unwrap(); + assert!((full.x0 - 24.0).abs() < 1e-6, "left indent + first-line indent: {full:?}"); +} + +// ---------- all of them ---------- + +/// One area type object with everything on: two paragraphs with their own attributes (the first +/// ragged every-line with inline symbols and lightly tracked ligatures, the second single-line, +/// justified and spaced away), centred vertically, shrunk to fit. +#[test] +fn every_feature_at_once() { + let rules = serif(14.0, 15.0); + let flavor = CharStyle { font_style: "Italic".into(), ..serif(14.0, 0.0) }; + let mut runs = vec![]; + for (i, words) in + ["Sacrifice a fine fish", "First strike. When this finishes fighting, draw a card", "Fifty fireflies fill the field"].iter().enumerate() + { + runs.push(sym(0.8, 0.0, rules.clone())); + runs.push(run(&format!(": {words}. "), rules.clone())); + if i == 1 { + runs.push(sym(0.8, 0.5, rules.clone())); + runs.push(run(" Then fight again.", rules.clone())); + } + } + runs.push(run("\nThe finest fishers find their fortune in the fjords, if they first fix their nets.", flavor)); + let frame = Rect::new(0.0, 0.0, 240.0, 110.0); + let mut t = area(runs, frame); + let mut flav = t.para.clone(); + flav.space_before = 10.0; + flav.justify = Justify::JustifyLeft; + flav.composer = Composer::SingleLine; + t.para.composer = Composer::EveryLine; + t.set_paragraph_styles(vec![t.para.clone(), flav]); + t.area.fit = AreaFit::ShrinkText { min_percent: 30.0 }; + + let top = layout(db(), &t); + t.area.vertical_align = VerticalAlign::Center; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_eq!(l.fit_scale, top.fit_scale); + // Shrunk: the same as laying out the scaled text, then centring it. + let mut unfit = scaled_by(&t, l.fit_scale); + unfit.area.vertical_align = VerticalAlign::Top; + let plain = layout(db(), &unfit); + assert_eq!(line_texts(&t, &l), line_texts(&t, &plain)); + let d = slack(&plain, frame) * 0.5; + assert!(d > 0.0); + for (b, b0) in baselines(&l).iter().zip(baselines(&plain)) { + assert!((b - b0 - d).abs() < 1e-6, "centred: {b} vs {b0} + {d}"); + } + // Every symbol on its glyph; ligatures kept at tracking 15. + assert_inlines_on_their_glyphs(&t, &l, "everything"); + assert_eq!(l.inlines.len(), 4); + assert!(ligatures(&l) >= 6, "{}", ligatures(&l)); + // The flavor paragraph: justified (all but its last line fill the measure) and spaced away. + let split = t.plain_text().find('\n').unwrap() + 1; + let flavor_lines: Vec<&LineInfo> = l.lines.iter().filter(|li| li.start >= split).collect(); + assert!(flavor_lines.len() >= 2); + for li in &flavor_lines[..flavor_lines.len() - 1] { + assert!((li.x1 - li.x0 - 240.0).abs() < 0.5, "justified: {li:?}"); + } + // Deterministic. + let again = layout(db(), &t); + assert_eq!(baselines(&again), baselines(&l)); + assert_eq!(again.inlines.iter().map(|i| i.bounds).collect::>(), l.inlines.iter().map(|i| i.bounds).collect::>()); +} + +/// The natural width of `s` set in one line at `size`. +fn natural_width(s: &str, size: f64) -> f64 { + let l = layout(db(), &TextObject::point(Point::ZERO, s, sans(size))); + l.lines[0].x1 - l.lines[0].x0 +} From 861a0759b0168bea4e5bd475a47f003e7ef1fe2b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 10:33:47 +0700 Subject: [PATCH 74/74] Part of #449: two fingers dragged on a touch screen pan the canvas A pinch already zoomed the canvas and a second finger already cancelled the first finger's press, but dragging two fingers together did nothing: the canvas only read wheel scrolls, and a touch screen sends touches. The fingers' movement (egui's multi-touch translation) now scrolls the canvas under either Zoom with Mouse Wheel setting, and zoom and scroll apply in the same frame, so a pinch that also slides pans too. Trackpads send scrolls, so nothing counts twice. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 64 +++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e03763ef..8496cebf 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); 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); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 161db60f..a29fb46f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -516,17 +516,19 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: prev.is_some_and(|prev| prev != m) }); - // Zoom around the pointer, or scroll ([`wheel`]). + // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also + // slide. if resp.hovered() { - if (factor - 1.0).abs() > 1e-6 { - if let (Some(p), Some(vm)) = (hover, app.view_mut()) { - let before = xf.to_doc(p); - vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0); - let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }; - let after = nx.to_doc(p); - vm.center += before - after; - } - } else if scroll != egui::Vec2::ZERO + if (factor - 1.0).abs() > 1e-6 + && let (Some(p), Some(vm)) = (hover, app.view_mut()) + { + let before = xf.to_doc(p); + vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0); + let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }; + let after = nx.to_doc(p); + vm.center += before - after; + } + if scroll != egui::Vec2::ZERO && let Some(vm) = app.view_mut() { let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll); @@ -673,13 +675,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: /// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). const WHEEL_ZOOM: f64 = 0.01; -/// What the wheel and a pinch did over the canvas this frame: a zoom factor (about the pointer) -/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option +/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: +/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option /// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel /// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points /// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the /// caller across frames). fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) { + // Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead). + let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta); if !wheel_zooms { // egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few // frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go. @@ -688,14 +692,14 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn } let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); return if *alt_turn && scroll != egui::Vec2::ZERO { - (zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO) + (zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan) } else { - (zoom, scroll) + (zoom, scroll + pan) }; } // The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom. let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta)); - let mut scroll = egui::Vec2::ZERO; + let mut scroll = pan; for e in &i.events { match e { egui::Event::MouseWheel { unit, delta, modifiers, .. } => { @@ -2658,6 +2662,36 @@ mod tests { assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); } + /// Two fingers dragged together on a touch screen pan the canvas with them, under either + /// Zoom with Mouse Wheel setting, and draw nothing (#449). + #[test] + fn two_fingers_dragged_together_pan_the_canvas() { + for wheel_zooms in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)]; + let before = *app.view().unwrap(); + // egui-winit moves the pointer with the first finger, as here. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]); + frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO)); + for k in 1..=3 { + frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32)); + } + frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0))); + let after = *app.view().unwrap(); + let moved = (after.center - before.center) * after.zoom; + assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}"); + assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn"); + } + } + #[test] fn cmd_space_zooms_and_space_pans_whatever_the_tool() { use egui::{Event, Modifiers};