Fix #734: Crop Image shows a crop box on the image

Object › Crop Image and the image's Crop Image buttons (Control bar,
Properties) now show a crop box on the selected image, as Illustrator
does, instead of cropping straight to the artboard (which did nothing for
an image inside its artboard). The box starts on the image's part over its
artboard; its handles and its inside drag it, always within the image, and
the image outside it is dimmed. The Control bar shows its X/Y/W/H with
Apply and Cancel; Enter applies and Escape cancels, both back to the
Selection tool. Apply runs object.cropImage {rect}, one undo step.

- tools: the Crop Image tool (cropimage.rs), with crop_box shared by the
  tool and the Control bar.
- ui: ui.cropImage (enabled with an image selected), the Control bar's
  crop fields and buttons, "Apply" translated (es, fr, it, ru, uk, ja,
  zh-hans, zh-hant).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 23:40:12 +07:00
1 parent 8e0e2eb904
commit 4b44265965
16 files changed
+452 -7

No files matched your search

+2 -2
View File
@@ -149,7 +149,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
@@ -222,7 +222,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker, Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740) | 2–4 |
+343
View File
@@ -0,0 +1,343 @@
//! Crop Image (#734): a crop box over the selected image. It starts on the part of the image over
//! its artboard (the whole image when it lies inside one); its handles and its inside drag it,
//! always within the image, and the part outside it is dimmed. Enter (or the Control bar's Apply)
//! crops the image to it (`object.cropImage {rect}`), Escape (or Cancel) leaves it as it was; both
//! go back to the Selection tool. The Control bar edits the box through the `rect` option.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle};
use crate::xform::CYAN;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id; Object › Crop Image and the image's Crop Image buttons choose it.
pub const ID: &str = "cropImage";
/// The smallest the box gets, in points.
const MIN: f64 = 1.0;
/// Create the Crop Image tool by id (None = not ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
(id == ID).then(|| Box::new(CropImageTool::default()) as Box<dyn Tool>)
}
/// The selected image (the first image of the selection) and its bounds.
pub fn selected_image(doc: &Document, sel: &Selection) -> Option<(NodeId, Rect)> {
sel.objects.iter().find_map(|id| {
let n = doc.node(*id)?;
matches!(n.kind, NodeKind::Image(_)).then_some(())?;
Some((*id, n.geometric_bounds()?))
})
}
/// Where the box starts for an image with bounds `image`: its part over the artboard its centre is
/// on, or all of it when it lies inside that artboard or isn't on one.
pub fn initial_box(doc: &Document, image: Rect) -> Rect {
doc.artboard_at(image.center())
.and_then(|i| doc.artboards.get(i))
.map(|a| a.rect.intersect(image))
.filter(|r| r.width() >= MIN && r.height() >= MIN)
.unwrap_or(image)
}
/// The selected image, its bounds and the box over it: `set` kept within the image, or where the
/// box starts ([`initial_box`]).
pub fn crop_box(doc: &Document, sel: &Selection, set: Option<Rect>) -> Option<(NodeId, Rect, Rect)> {
let (id, image) = selected_image(doc, sel)?;
Some((id, image, set.map_or_else(|| initial_box(doc, image), |r| within(r, image))))
}
/// `r` (moved whole) slid back within `image` without changing its size; `r` lies within `image`
/// to begin with, so it fits.
fn slid_within(r: Rect, image: Rect) -> Rect {
let x0 = r.x0.min(image.x1 - r.width()).max(image.x0);
let y0 = r.y0.min(image.y1 - r.height()).max(image.y0);
Rect::new(x0, y0, x0 + r.width(), y0 + r.height())
}
/// `r` cut to `image`, at least [`MIN`] wide and high.
fn within(r: Rect, image: Rect) -> Rect {
let r = r.abs().intersect(image);
let w = r.width().max(MIN).min(image.width());
let h = r.height().max(MIN).min(image.height());
let x0 = r.x0.min(image.x1 - w).max(image.x0);
let y0 = r.y0.min(image.y1 - h).max(image.y0);
Rect::new(x0, y0, x0 + w, y0 + h)
}
#[derive(Clone, Copy)]
enum Drag {
/// A handle, with the box as it was pressed.
Handle(Handle, Rect),
/// The box itself, from where it was pressed.
Move(Point, Rect),
}
#[derive(Default)]
pub struct CropImageTool {
/// The box, for the image it was made for (None: set from outside, for the selected image);
/// None until it's dragged or set.
rect: Option<(Option<NodeId>, Rect)>,
drag: Option<Drag>,
}
impl CropImageTool {
/// The selected image, its bounds and the box over it.
fn current(&self, cx: &ToolContext) -> Option<(NodeId, Rect, Rect)> {
let (id, _) = selected_image(cx.doc, cx.selection)?;
let set = self.rect.filter(|(of, _)| of.is_none_or(|of| of == id)).map(|(_, r)| r);
crop_box(cx.doc, cx.selection, set)
}
/// Crop to the box and go back to the Selection tool (only back when it covers the image).
fn apply(&mut self, cx: &ToolContext) -> Vec<Action> {
let current = self.current(cx);
self.reset();
let mut out = vec![];
if let Some((_, image, r)) = current
&& !same(r, image)
{
out.push(Action::Exec("object.cropImage".into(), json!({ "rect": [r.x0, r.y0, r.width(), r.height()] })));
}
out.push(Action::SwitchTool("selection".into()));
out
}
fn reset(&mut self) {
self.rect = None;
self.drag = None;
}
}
/// Whether two rectangles are the same to a thousandth of a point.
fn same(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-3)
}
/// The box `r` with the sides handle `h` holds moved to `p`.
fn resized(r: Rect, h: Handle, p: Point) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (r.x0, r.y0, r.x1, r.y1);
match h {
Handle::TopLeft | Handle::Left | Handle::BottomLeft => x0 = p.x.min(x1 - MIN),
Handle::TopRight | Handle::Right | Handle::BottomRight => x1 = p.x.max(x0 + MIN),
Handle::Top | Handle::Bottom => {}
}
match h {
Handle::TopLeft | Handle::Top | Handle::TopRight => y0 = p.y.min(y1 - MIN),
Handle::BottomLeft | Handle::Bottom | Handle::BottomRight => y1 = p.y.max(y0 + MIN),
Handle::Left | Handle::Right => {}
}
Rect::new(x0, y0, x1, y1)
}
impl Tool for CropImageTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let Some((id, image, r)) = self.current(cx) else { return vec![] };
match ev.kind {
PointerKind::Down => {
self.drag = match hit_handle(r, ev.pos, cx.tol(5.0)) {
Some(h) => Some(Drag::Handle(h, r)),
None => r.contains(ev.pos).then_some(Drag::Move(ev.pos, r)),
};
}
PointerKind::Drag => {
let next = match self.drag {
Some(Drag::Handle(h, from)) => within(resized(from, h, ev.pos), image),
Some(Drag::Move(start, from)) => {
let d: Vec2 = ev.pos - start;
slid_within(from + d, image)
}
None => return vec![],
};
self.rect = Some((Some(id), next));
}
PointerKind::Up => self.drag = None,
PointerKind::DoubleClick => return self.apply(cx),
PointerKind::Move => {}
}
vec![]
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Enter => self.apply(cx),
ToolKey::Escape => {
self.reset();
vec![Action::SwitchTool("selection".into())]
}
_ => vec![],
}
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Enter | ToolKey::Escape) && self.current(cx).is_some()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((_, image, r)) = self.current(cx) else { return vec![] };
let shade = [0, 0, 0, 110];
let quad = |x0: f64, y0: f64, x1: f64, y1: f64| [Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)];
// The image outside the box, dimmed: above, below, then left and right of it.
let mut out: Vec<Overlay> = [
quad(image.x0, image.y0, image.x1, r.y0),
quad(image.x0, r.y1, image.x1, image.y1),
quad(image.x0, r.y0, r.x0, r.y1),
quad(r.x1, r.y0, image.x1, r.y1),
]
.into_iter()
.filter(|q| (q[2].x - q[0].x) > 1e-9 && (q[2].y - q[0].y) > 1e-9)
.map(|quad| Overlay::Highlight { quad, color: shade })
.collect();
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
out.extend(corners.iter().zip(corners.iter().cycle().skip(1)).map(|(a, b)| Overlay::Line { a: *a, b: *b, color: CYAN, dashed: false }));
out.extend(Handle::ALL.iter().map(|h| Overlay::Handle { p: h.pos(r), color: CYAN }));
out.push(Overlay::Label { p: Point::new(r.x0, r.y1), text: cx.size_label(r.width(), r.height()), color: CYAN });
out
}
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
let Some((_, _, r)) = self.current(cx) else { return Cursor::Arrow };
match hit_handle(r, p, cx.tol(5.0)) {
Some(Handle::Top | Handle::Bottom) => Cursor::ResizeV,
Some(Handle::Left | Handle::Right) => Cursor::ResizeH,
Some(Handle::TopLeft | Handle::BottomRight) => Cursor::ResizeNwSe,
Some(Handle::TopRight | Handle::BottomLeft) => Cursor::ResizeNeSw,
None if r.contains(p) => Cursor::Move,
None => Cursor::Arrow,
}
}
/// `rect`: the box as `[x, y, width, height]` once it's been dragged or set (until then it's
/// [`initial_box`]).
fn options(&self) -> Value {
match self.rect {
Some((_, r)) => json!({ "rect": [r.x0, r.y0, r.width(), r.height()] }),
None => json!({}),
}
}
/// `rect` `[x, y, width, height]` sets the box (kept within the selected image when used);
/// anything else puts it back where it starts.
fn set_option(&mut self, key: &str, value: &Value) {
if key != "rect" {
return;
}
let n: Vec<f64> = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default();
self.rect = match n.as_slice() {
[x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))),
_ => None,
};
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.reset();
vec![]
}
}
#[cfg(test)]
mod tests {
use vectorcraft_doc::{ImageObject, Node};
use vectorcraft_geom::Affine;
use super::*;
use crate::testutil::{cx, paint};
/// A 500 × 500 document (one artboard) with a 100 × 50 px image drawn at twice that size from
/// `at`, selected.
fn doc_with_image(at: (f64, f64)) -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let im = ImageObject {
key: "k".into(),
width: 100,
height: 50,
xf: Affine::translate(at) * Affine::scale(2.0),
link: None,
placement: Default::default(),
};
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(im))).unwrap();
let mut s = Selection::default();
s.add(id);
(d, s)
}
fn drag(t: &mut CropImageTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64)) {
for (kind, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
assert!(t.pointer(cx, &PointerEvent::new(kind, x, y)).is_empty());
}
}
fn crop_rect(acts: &[Action]) -> Option<Vec<f64>> {
acts.iter().find_map(|a| match a {
Action::Exec(c, v) if c == "object.cropImage" => Some(v["rect"].as_array()?.iter().filter_map(Value::as_f64).collect()),
_ => None,
})
}
#[test]
fn the_box_is_dragged_by_its_handles_and_inside_within_the_image() {
let (d, s) = doc_with_image((50.0, 60.0));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = CropImageTool::default();
// An image inside its artboard: the box starts on all of it, and Enter has nothing to cut.
assert_eq!(t.cursor(&cx, Point::new(50.0, 60.0), Mods::default()), Cursor::ResizeNwSe);
assert_eq!(t.cursor(&cx, Point::new(150.0, 110.0), Mods::default()), Cursor::Move);
assert!(t.claims_key(&cx, ToolKey::Enter));
// The top-left handle in, then the box dragged left past the image's edge: it stops there.
drag(&mut t, &cx, (50.0, 60.0), (80.0, 70.0));
assert_eq!(t.options()["rect"], json!([80.0, 70.0, 170.0, 90.0]));
drag(&mut t, &cx, (150.0, 110.0), (50.0, 110.0));
assert_eq!(t.options()["rect"], json!([50.0, 70.0, 170.0, 90.0]));
// A handle dragged past the opposite side leaves the box at least a point wide.
drag(&mut t, &cx, (220.0, 115.0), (0.0, 115.0));
assert_eq!(t.options()["rect"], json!([50.0, 70.0, 1.0, 90.0]));
drag(&mut t, &cx, (51.0, 115.0), (180.0, 115.0));
// The outside of the box is dimmed, the box drawn with its handles.
let ov = t.overlays(&cx);
assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Highlight { .. })).count(), 2, "above and right of the box");
assert_eq!(ov.iter().filter(|o| matches!(o, Overlay::Handle { .. })).count(), 8);
// Enter crops to it and goes back to the Selection tool.
let acts = t.key(&cx, ToolKey::Enter, Mods::default());
assert_eq!(crop_rect(&acts), Some(vec![50.0, 70.0, 130.0, 90.0]));
assert_eq!(acts.last(), Some(&Action::SwitchTool("selection".into())));
assert!(t.options().get("rect").is_none(), "the next crop starts afresh");
assert!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())).is_none(), "the whole image: nothing to cut");
}
#[test]
fn the_box_starts_on_the_part_over_the_artboard_and_escape_cancels() {
// 200 × 100 from (-50, 100): its left 50 pt hang off the artboard.
let (d, s) = doc_with_image((-50.0, 100.0));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = CropImageTool::default();
drag(&mut t, &cx, (100.0, 150.0), (110.0, 150.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
assert_eq!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())), Some(vec![0.0, 100.0, 150.0, 100.0]));
// The Control bar sets the box; it stays within the image.
t.set_option("rect", &json!([100, 120, 500, 20]));
assert_eq!(crop_rect(&t.key(&cx, ToolKey::Enter, Mods::default())), Some(vec![100.0, 120.0, 50.0, 20.0]));
// Nothing selected: no box, and the keys go elsewhere.
let none = Selection::default();
let cx = crate::testutil::cx(&d, &none, &p);
assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter));
}
#[test]
fn the_tool_is_made_by_its_id() {
assert_eq!(crate::create(ID).id(), ID);
}
}
+2
View File
@@ -11,6 +11,7 @@ pub mod bbox;
pub mod builder;
pub mod catalog;
pub mod corners;
pub mod cropimage;
pub mod cut;
pub mod direct;
pub mod distort;
@@ -591,6 +592,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| extra::create(other))
.or_else(|| slice::create(other))
.or_else(|| printtiling::create(other))
.or_else(|| cropimage::create(other))
.or_else(|| cut::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
+1
View File
@@ -161,6 +161,7 @@
Appearance panel Panel Apariencia
Applies when the linked file is relinked or updated. Se aplica al volver a vincular o actualizar el archivo vinculado.
Applies the next time VectorCraft starts. Se aplica la próxima vez que se inicie VectorCraft.
Apply Aplicar
Apply Brush Aplicar pincel
Apply Character Style Aplicar estilo de carácter
Apply Effect Aplicar efecto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -162,6 +162,7 @@
Appearance panel Panneau Aspect
Applies when the linked file is relinked or updated. S’applique lorsque le fichier lié est de nouveau lié ou mis à jour.
Applies the next time VectorCraft starts. S’applique au prochain démarrage de VectorCraft.
Apply Appliquer
Apply Brush Appliquer la forme
Apply Character Style Appliquer le style de caractère
Apply Effect Appliquer l’effet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -161,6 +161,7 @@
Appearance panel Pannello Aspetto
Applies when the linked file is relinked or updated. Si applica quando il file collegato viene ricollegato o aggiornato.
Applies the next time VectorCraft starts. Si applica al prossimo avvio di VectorCraft.
Apply Applica
Apply Brush Applica pennello
Apply Character Style Applica stile carattere
Apply Effect Applica effetto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -342,6 +342,7 @@
Same 共通
Appearance アピアランス
Appearance Attribute アピアランス属性
Apply 適用
Blending Mode 描画モード
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -159,6 +159,7 @@
Appearance panel Панель «Оформление»
Applies when the linked file is relinked or updated. Применяется при повторной привязке или обновлении связанного файла.
Applies the next time VectorCraft starts. Применяется при следующем запуске VectorCraft.
Apply Применить
Apply Brush Применить кисть
Apply Character Style Применить стиль символа
Apply Effect Применить эффект
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -155,6 +155,7 @@
Appearance panel Панель «Оформлення»
Applies when the linked file is relinked or updated. Використовується при повторній прив’язці або оновленні пов’язаного файлу.
Applies the next time VectorCraft starts. Застосовується при наступному запуску VectorCraft.
Apply Застосувати
Apply Brush Застосувати пензель
Apply Character Style Застосувати стиль символу
Apply Effect Застосувати ефект
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
155
156
157
158
159
160
161
+1
View File
@@ -144,6 +144,7 @@
Appearance Panel 外观面板
Appearance panel 外观面板
Applies when the linked file is relinked or updated. 在重新链接或更新链接文件时应用。
Apply 应用
Apply Brush 应用画笔
Apply Character Style 应用字符样式
Apply Effect 应用效果
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -155,6 +155,7 @@
Appearance panel 外觀面板
Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。
Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。
Apply 套用
Apply Brush 套用筆刷
Apply Character Style 套用字元樣式
Apply Effect 套用效果
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13 -1
View File
@@ -285,6 +285,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order; with no art selected and no group they are the rows, and OK saves them as a new colour group, field `groupName`: the Color Guide's Edit or Apply Colors), library?: id or name, or \"document\" (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
),
(
"ui.cropImage",
"Crop Image",
"",
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
(
"file.export.pdf",
@@ -1140,6 +1146,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"tool.setOption" => app.session.set_tool_option_cmd(p),
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
"ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()),
"ui.cropImage" => {
app.select_tool(vectorcraft_tools::cropimage::ID);
Ok(json!({"tool": app.session.tool_id()}))
}
"ui.paramDialog" => {
let cmd = s("command").unwrap_or_default();
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
@@ -1839,6 +1850,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
id["view.goto".len()..].parse::<usize>().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len()))
}
"effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.cropImage" => selected_image(app, |_| true),
"effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
"file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
@@ -2057,7 +2069,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
c("Expand…", "ui.expandDialog"),
c("Expand Appearance", "effect.expandAppearance"),
c("Crop Image", "object.cropImage"),
c("Crop Image", "ui.cropImage"),
c("Rasterize…", "object.rasterize"),
cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})),
cp(
+1 -1
View File
@@ -119,7 +119,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
if is_image {
crate::panels::image_trace::trace_button(app, ui, ui.available_width());
actions.push((tl!("Crop Image"), "object.cropImage"));
actions.push((tl!("Crop Image"), "ui.cropImage"));
actions.push((tl!("Mask"), "object.maskImage"));
}
actions.push((tl!("Offset Path"), "object.path.offsetPath"));
+1 -1
View File
@@ -436,7 +436,7 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool
ui.separator();
link_buttons(app, ui, id, linked, &name, None);
crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W);
for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "object.cropImage", 84.0)] {
for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0)] {
if widgets::flat_button(ui, label, w).clicked() {
crate::menus::invoke(app, cmd, json!({}));
}
+41
View File
@@ -407,3 +407,44 @@ fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() {
let d = app.ui.dialog.as_ref().unwrap();
assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents"));
}
/// Tool key `key` as the Control bar's Apply (Enter) and Cancel (Escape) send it.
fn tool_key(app: &mut VectorcraftApp, key: vectorcraft_tools::ToolKey) {
let view = app.view_info();
let r = app.session.tool_key(key, vectorcraft_tools::Mods::default(), view);
crate::canvas::apply_requests(app, r);
}
/// #734: Crop Image shows a crop box on the image instead of cropping to the artboard at once (an
/// image inside the artboard had nothing to cut); Apply crops to the box in one undo step, Cancel
/// leaves the image whole, and both go back to the Selection tool.
#[test]
fn crop_image_shows_a_box_that_apply_crops_to() {
use vectorcraft_tools::ToolKey;
let mut app = app();
place_picked(&mut app, &temp_file("crop.png", &png(100, 50, 72.0)));
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
let whole = selected_image(&app).geometric_bounds().unwrap();
assert!(crate::menus::enabled(&app, "ui.cropImage"));
app.run("ui.cropImage", json!({})).unwrap();
assert_eq!(app.session.tool_id(), "cropImage");
tool_key(&mut app, ToolKey::Escape);
assert_eq!(app.session.tool_id(), "selection");
assert_eq!(selected_image(&app).geometric_bounds(), Some(whole), "Cancel leaves it whole");
// The box set to the image's left half, as the Control bar's fields set it, then Apply.
app.run("ui.cropImage", json!({})).unwrap();
app.run("tool.setOption", json!({"key": "rect", "value": [whole.x0, whole.y0, whole.width() / 2.0, whole.height()]})).unwrap();
tool_key(&mut app, ToolKey::Enter);
assert_eq!(app.session.tool_id(), "selection");
let b = selected_image(&app).geometric_bounds().unwrap();
assert!(
(b.x0 - whole.x0).abs() < 1e-6 && (b.width() - whole.width() / 2.0).abs() < 1e-6 && (b.height() - whole.height()).abs() < 1e-6,
"{b:?} of {whole:?}"
);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(selected_image(&app).geometric_bounds(), Some(whole), "one undo step");
// Without an image selected there's nothing to crop.
app.run("select.none", json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err());
}
+41 -2
View File
@@ -5,9 +5,9 @@
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar.
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info};
use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, widgets};
@@ -350,6 +350,9 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.session.tool_id() == "puppetWarp" {
return puppet_warp_options(app, ui);
}
if app.session.tool_id() == vectorcraft_tools::cropimage::ID {
return crop_options(app, ui);
}
if app.session.tool_id() == "artboard" {
crate::panels::artboards::art_options(app, ui);
ui.separator();
@@ -372,6 +375,42 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
}
/// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's
/// `rect` option, then Apply (Enter) and Cancel (Escape).
fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
let set = app.session.tool_options()["rect"].as_array().and_then(|v| match v.iter().filter_map(Value::as_f64).collect::<Vec<_>>()[..] {
[x, y, w, h] => Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h)),
_ => None,
});
let Some((_, _, r)) = app.session.active().and_then(|st| vectorcraft_tools::cropimage::crop_box(&st.doc, &st.selection, set)) else { return };
let t = Tokens::get(ui.ctx());
let units = app.session.general_unit();
let c = r.center();
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", r.width()), ("height", "H:", r.height())] {
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb-crop", k), Some(v), units, 80.0) {
let (mut c, mut w, mut h) = (c, r.width(), r.height());
match k {
"x" => c.x = nv,
"y" => c.y = nv,
"width" => w = nv.max(0.0),
_ => h = nv.max(0.0),
}
let rect = [c.x - w / 2.0, c.y - h / 2.0, w, h];
app.run("tool.setOption", json!({ "key": "rect", "value": rect })).ok();
}
}
ui.separator();
for (label, key) in [(tl!("Apply"), ToolKey::Enter), (tl!("Cancel"), ToolKey::Escape)] {
if widgets::flat_button(ui, label, 64.0).clicked() {
let view = app.view_info();
let r = app.session.tool_key(key, Mods::default(), view);
crate::canvas::apply_requests(app, r);
}
}
ui.separator();
}
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
ui.add_space(5.0);