From 2f8b4ddeca6fff710d5316ab5f0be880ba94dbb8 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 19:40:18 -0700 Subject: [PATCH 01/27] 3D: a tapered Extrude & Bevel wall is one face Extruded type with a taper showed a row of light ticks along its walls, and with a stroke a long light line down a wall (#7019). A taper only scales each wall's far edge toward the center of the bounds as it goes back: the scale is linear in depth, so without a twist each wall stays a flat trapezoid, bevel bands included. The walls were cut into 8 slices all the same, and at every joint between slices a neighbor's seam painted a light tick onto the wall. Walls are now cut into slices only for a twist, which bends them; the ticks and the long line are gone from tapered, untwisted walls. Twisted walls and Inflate's sides are sliced as before. A tapered wall now draws like an untapered one, with three effects: - A wall seen almost edge-on ends in one longer sliver instead of the last of its slices. With mitered seams that sliver's thorn is longer; round seam joins (#6980) keep it within half the seam's width. - Its seam is as wide as an untapered wall's (the width follows the face's thickness, up to 0.75 pt), wider than a slice's was. - It is sorted by one depth, like an untapered wall, so at a concave corner it can be drawn over a nearer face where the slices had happened to avoid that (#7023). Test: solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced checks that a tapered square's four walls are four flat faces, that a taper adds no faces to a circle's walls, with bevels (both sides, inside), in perspective or hollow, and that a twisted wall is still sliced. With taper slicing it fails (32 wall faces for the square's 4, 608 for the circle's 76). Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/three-d/src/solid/build.rs | 5 ++-- crates/three-d/src/solid/tests.rs | 44 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 3 ++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index bd4030dfc4..d8c18f292f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -505,8 +505,9 @@ fn steps(o: &Extrude) -> Vec { } else { out.push(Step { inset: 0.0, t: 0.0, face: 1.0 }); } - // Twist and taper bend the walls: cut them into slices. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + // A twist bends the walls: cut them into slices. A taper alone keeps each wall flat (its edge + // only scales toward the center as it goes back), so it needs none. + let slices = if o.twist != 0.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 }; let (t0, t1) = (height / d, 1.0 - back_height / d); for k in 0..=slices { diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index be887180b2..123462b41c 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -154,6 +154,50 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(open.len() > 2 * 8, "{}", open.len()); } +/// A taper only scales each wall's far edge toward the center, so the wall stays one flat face; +/// cut into slices, each joint between them showed a light tick. A twist bends the walls, which +/// are still cut into slices. +#[test] +fn a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: -35.0, rotation_y: 25.0, ..front() }; + // Every side facet, the ones turned away included. + let sides = |o: Extrude, v: View| -> Vec { + extrude(&p, FillRule::NonZero, b, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).collect() + }; + let o = Extrude { depth: 40.0, taper: 0.475, ..Extrude::default() }; + let walls = sides(o, v); + assert_eq!(walls.len(), 4, "one face for each of a square's four walls"); + for f in &walls { + let [ring] = &f.view[..] else { panic!("{} contours", f.view.len()) }; + let n = super::build::newell(std::slice::from_ref(ring)); + let len = n.0.hypot(n.1).hypot(n.2); + let off = |q: &V3| ((q.0 - ring[0].0) * n.0 + (q.1 - ring[0].1) * n.1 + (q.2 - ring[0].2) * n.2) / len; + assert!(ring.iter().all(|q| off(q).abs() < 1e-9), "a flat wall: {:?}", ring.iter().map(off).collect::>()); + } + // Beveled, in perspective or hollow, a taper adds no faces either. + let bevel = Bevel { width: 8.0, height: 8.0, ..Bevel::default() }; + let perspective = View { perspective: 60.0, ..v }; + for (o, v) in [ + (Extrude { bevel: Some(bevel), ..o }, v), + (Extrude { bevel: Some(Bevel { both_sides: true, ..bevel }), ..o }, v), + (Extrude { bevel: Some(Bevel { inside: true, shape: BevelShape::Step, ..bevel }), ..o }, v), + (o, perspective), + (Extrude { cap: false, ..o }, perspective), + ] { + assert_eq!(sides(o, v).len(), sides(Extrude { taper: 1.0, ..o }, v).len(), "{o:?} {v:?}"); + } + // A curved outline's walls take one band each too: a taper adds no faces to a circle's. + let (cp, cb) = shape(&circle(Point::new(50.0, 50.0), 50.0)); + let round = |o: Extrude| extrude(&cp, FillRule::NonZero, cb, Extrude { hidden: true, ..o }, v).unwrap().into_iter().filter(|f| f.side).count(); + assert_eq!(round(o), round(Extrude { taper: 1.0, ..o }), "a tapered circle's walls"); + // A twist still cuts each wall into at least 8 slices, tapered or not. + for taper in [1.0, o.taper] { + let twisted = sides(Extrude { twist: 30.0, taper, ..o }, v).len(); + assert!(twisted >= 4 * 8, "{twisted} faces for a twisted square's walls"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..5ea962d294 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1156,6 +1156,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. + - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index 2eb3e6c7fe..109acc2f45 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -77,7 +77,7 @@ object is where the art was. |---|---| | Depth | 0–2,000 pt. At 0 the object is flat (the art turned like Rotate). | | Twist | 0° to 360°: the back face turned against the front, the walls following in slices. | -| Taper | 1–100 %: the back face's size against the front's. | +| Taper | 1–100 %: the back face's size against the front's. Without a twist, each wall (and each bevel band) stays flat, so it is one face, not cut into slices. | | Cap | **Solid** closes the front and back; **Hollow** leaves them open, showing the insides of the walls. | | Bevel Shape | **None**, **Classic** (a straight chamfer), **Rounded** (convex quarter round), **Cove** (concave), **Step** (two steps) or **Ogee** (an S curve). Our own cross-sections. | | Width, Height | How far the bevel reaches across the face and along the depth (0–1,000 pt each). The height is limited to the depth (half of it on each side with Bevel both sides). | @@ -418,6 +418,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-12 | Extrude & Bevel cuts its walls into slices only for a twist. A taper alone keeps each wall flat, so it is one face, without the light ticks at the joints between slices or the light line down a wall (#7019). Inflate still slices its sides for a taper. | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 1c0310a1d510c38e9576376a9ea9985b9f8cff4f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:17:54 -0800 Subject: [PATCH 02/27] Smooth: Fidelity is in screen pixels, so a drag smooths as much at any zoom The Smooth tool, and an Alt smoothing drag with the Pencil, Paintbrush or Blob Brush, now send path.smoothRegion their Fidelity divided by the zoom, as the Pencil and the Paintbrush do since #5748. Smooth Tool Options shows Fidelity in px. A saved Fidelity keeps its number, so it smooths as before at 100% zoom. path.smoothRegion now refits at tolerances from 0.05 px at the highest zoom to 100 px at the lowest (0.000078125 to about 3195 pt) instead of clamping to 0.05 to 100 pt, so the setting keeps scaling across the whole zoom range. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/smooth.rs | 2 +- crates/engine/src/tests/draw2.rs | 26 +++++++++++++++++++ crates/mcp/src/ui_catalog.rs | 2 +- crates/tools/src/draw2.rs | 10 +++++++- crates/tools/src/draw2/gesture.rs | 17 +++++++------ crates/tools/src/draw2/gesture/tests.rs | 33 ++++++++++++++++++++++++- crates/ui-egui/src/dialogs/freehand.rs | 11 +++++---- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/control-protocol.md | 2 +- docs/mcp.md | 5 ++++ docs/target-app-parity.md | 1 + 12 files changed, 94 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 8946aa4351..342eda6e33 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "Smooth", [], None, - "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}", + "{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}", has_selection, smooth_region ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index d63bdaea77..9227095684 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -137,7 +137,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.smoothRegion"; let pts = points_param(p, "points", C)?; let radius = number_or(p, "radius", 12.0, C)?.max(0.0); - let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE); let st = s.doc()?; let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 5986b618fd..6239e75624 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1367,6 +1367,32 @@ fn smooth_tool_reduces_jagged_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); } +/// The anchors a zigzag `amplitude` points high keeps after one `path.smoothRegion` drag along it +/// at `fidelity`, with the path, the drag, its radius and the Fidelity all scaled by `scale`: the +/// same drag on screen zoomed in 1 / `scale` times. +fn smoothed_zigzag_anchors(amplitude: f64, fidelity: f64, scale: f64) -> usize { + let mut s = session(); + let anchors: Vec<_> = + (0..=40).map(|i| json!({"x": (100.0 + i as f64 * 5.0) * scale, "y": (200.0 + if i % 2 == 0 { 0.0 } else { amplitude }) * scale})).collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let drag: Vec<_> = [(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)].iter().map(|&(x, y)| json!([x * scale, y * scale])).collect(); + s.execute("path.smoothRegion", &json!({"points": drag, "radius": 12.0 * scale, "fidelity": fidelity * scale})).unwrap(); + path(&s, id).anchor_count() +} + +/// The Smooth tool sends its Fidelity (screen pixels) in points at Fidelity / zoom, so +/// `path.smoothRegion` refits at the tolerance it is sent from the lowest Fidelity at the highest +/// zoom (0.05 px at 64000 %) to the highest at the lowest zoom (100 px at 3.13 %): a drag smooths +/// as much on screen at any zoom. +#[test] +fn smooth_region_refits_at_the_tolerance_sent_at_any_zoom() { + for (amplitude, fidelity, scale) in [(1.5, 0.05, 1.0 / 640.0), (1.5, 0.05, 1.0 / 8.0), (5.0, 20.0, 1.0 / 0.0313), (5.0, 20.0, 4.0)] { + let want = smoothed_zigzag_anchors(amplitude, fidelity, 1.0); + assert_eq!(smoothed_zigzag_anchors(amplitude, fidelity, scale), want, "{amplitude} pt zigzag at {fidelity} px, zoom {}", 1.0 / scale); + } +} + /// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight /// sides next to the bump stay straight lines from their far ends, which keep their place. #[test] diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..9b3e729358 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -879,7 +879,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 123082aa25..b948397752 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -34,9 +34,17 @@ pub const MIN_FIDELITY: f64 = 0.01; /// Fidelity at the highest zoom (64000 %), whose screen pixels the tools send in points (#5729). pub const MIN_FIT_TOLERANCE: f64 = MIN_FIDELITY / 640.0; -/// The lowest Fidelity (points) the Smooth tool takes. +/// The lowest Fidelity (screen pixels) the Smooth tool takes. pub const SMOOTH_MIN_FIDELITY: f64 = 0.05; +/// The finest tolerance (points) `path.smoothRegion` refits at: the Smooth tool's lowest Fidelity at +/// the highest zoom (64000 %), whose screen pixels the tools send in points. +pub const SMOOTH_MIN_TOLERANCE: f64 = SMOOTH_MIN_FIDELITY / 640.0; + +/// The coarsest tolerance (points) `path.smoothRegion` refits at: the highest Fidelity (100 screen +/// pixels) at the lowest zoom (3.13 %). +pub const SMOOTH_MAX_TOLERANCE: f64 = 100.0 / 0.0313; + /// The lowest Fidelity tool `id` takes. pub fn min_fidelity(id: &str) -> f64 { if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY } diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index e4811418c7..c8312f2045 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -39,7 +39,7 @@ const SMOOTH_RADIUS: f64 = 12.0; /// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and /// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity. const PREVIEW_FIDELITY_PX: f64 = 0.5; -/// The Smooth tool's Fidelity until Smooth Tool Options sets another. +/// The Smooth tool's Fidelity (screen pixels) until Smooth Tool Options sets another. const SMOOTH_FIDELITY: f64 = 2.5; pub struct GestureTool { @@ -67,8 +67,8 @@ pub struct GestureTool { /// Paths' distance in points); the stroke redraws the section it ends on (#2850). redraw: Option<(NodeId, f64)>, /// Fit tolerance (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"): in screen pixels for the - /// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), in points - /// for the others. + /// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), and for the + /// Smooth tool, so a drag smooths as much at any zoom; in points for the Blob Brush. pub fidelity: f64, /// Brush / eraser diameter in points (the Eraser's across its tip's long axis). pub size: f64, @@ -99,8 +99,8 @@ pub struct GestureTool { /// preview shows the points joined by straight segments and the release fits them (Pencil /// only, on by default). pub live_fit: bool, - /// The Smooth tool's Fidelity, used when Alt switches the Pencil, Paintbrush or Blob Brush to Smooth. - /// The engine sets it from Smooth Tool Options (`smoothFidelity`). + /// The Smooth tool's Fidelity (screen pixels), used when Alt switches the Pencil, Paintbrush or + /// Blob Brush to Smooth. The engine sets it from Smooth Tool Options (`smoothFidelity`). smooth_fidelity: f64, /// Active drag is smoothing with the Pencil, Paintbrush or Blob Brush (Alt held). smoothing: bool, @@ -483,7 +483,7 @@ impl GestureTool { if has_sel && pts.len() >= 2 { return vec![Action::Exec( "path.smoothRegion".into(), - json!({"points": points_json(pts), "radius": radius, "fidelity": self.smooth_fidelity}), + json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}), )]; } return vec![]; @@ -533,7 +533,10 @@ impl GestureTool { )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], "smooth" if has_sel && pts.len() >= 2 => { - vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))] + vec![Action::Exec( + "path.smoothRegion".into(), + json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.fidelity)}), + )] } "pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))], "join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))], diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index a0fe487289..6d87d9c2c0 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -539,7 +539,7 @@ fn blob_brush_alt_switches_to_the_smooth_tool() { let acts = drag_held(&mut t, &cx, &stroke, alt); assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}")); - assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": SMOOTH_FIDELITY})); + assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": cx.tol(SMOOTH_FIDELITY)})); // Mid-drag the cursor stays the Smooth tool's when Alt is let go, and the line is thin with // no length label. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt)); @@ -1020,6 +1020,37 @@ fn alt_smoothing_uses_the_smooth_tools_fidelity() { } } +/// The Smooth tool's Fidelity is in screen pixels: the Smooth tool and an Alt smoothing drag with +/// the Pencil, Paintbrush or Blob Brush send it in points at Fidelity / zoom, so a drag smooths as +/// much on screen at any zoom. +#[test] +fn smooth_fidelity_is_in_screen_pixels_at_any_zoom() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let fidelity = |acts: &[Vec]| match acts.last().and_then(|a| a.first()) { + Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["fidelity"].as_f64().unwrap(), + a => panic!("expected path.smoothRegion, got: {a:?}"), + }; + let alt = Mods { alt: true, ..Default::default() }; + for zoom in [0.25, 1.0, 8.0] { + cx.zoom = zoom; + let mut t = GestureTool::new("smooth"); + t.set_option("fidelity", &json!(4.0)); + assert_eq!(fidelity(&drag(&mut t, &cx, &pts, Mods::default())), 4.0 / zoom, "smooth at {zoom}x"); + for (id, alt_smooth) in [("pencil", true), ("blobBrush", false), ("paintbrush", false)] { + let mut t = GestureTool::new(id); + t.set_option("altSmooth", &json!(alt_smooth)); + t.set_option("smoothFidelity", &json!(4.0)); + assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 4.0 / zoom, "{id} at {zoom}x"); + } + } +} + /// Alt pressed after a Paintbrush stroke begins closes it, and an empty selection still draws. #[test] fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws() { diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index c223c566fd..48f31731cc 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -3,7 +3,7 @@ //! preferences. OK sets them with `tool.setOption {tool, values}`. The Pencil also has `liveFit` //! (Live curve fitting, on: its preview fits the stroke while it is drawn). //! -//! Fields: `tool`; `fidelity` (screen pixels for the Pencil and Paintbrush, points for Smooth and +//! Fields: `tool`; `fidelity` (screen pixels for the Pencil, Paintbrush and Smooth, points for the //! Blob Brush), `fill` (Pencil and //! Paintbrush), `keepSelected` (Pencil, Paintbrush and Blob Brush), `mergeOnlySelection` (Blob //! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush), @@ -59,8 +59,8 @@ fn label_w(ui: &egui::Ui) -> f32 { CONTENT_W - FIELD_W - ui.spacing().item_spacing.x } -/// The top of the Fidelity slider, in the tool's unit (screen pixels for the Pencil and -/// Paintbrush, points for Smooth and Blob Brush); it starts at the lowest Fidelity the tool takes, +/// The top of the Fidelity slider, in the tool's unit (screen pixels for the Pencil, Paintbrush +/// and Smooth, points for the Blob Brush); it starts at the lowest Fidelity the tool takes, /// and the field takes up to 100. const FIDELITY_MAX: f64 = 20.0; @@ -170,8 +170,9 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) { let v = d.f64("fidelity", 1.5); let tool = d.str("tool"); let rail = (vectorcraft_tools::draw2::min_fidelity(&tool), FIDELITY_MAX); - // The Pencil's and the Paintbrush's Fidelity is on screen, the same at any zoom (#5729). - let unit = if matches!(tool.as_str(), "pencil" | "paintbrush") { "px" } else { "pt" }; + // The Pencil's, the Paintbrush's and the Smooth tool's Fidelity is on screen, the same at any + // zoom (#5729). + let unit = if matches!(tool.as_str(), "pencil" | "paintbrush" | "smooth") { "px" } else { "pt" }; let rail_w = label_w(ui); let mut new = None; ui.label(egui::RichText::new(tl!("Fidelity:")).size(12.5).color(t.text)); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..14306c5095 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -880,7 +880,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5694d9582d..dafe9fc38e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -942,7 +942,7 @@ events take `pressure` (0..1, default 1): it is the Liquify intensity while Use Freehand Tool Options: double-clicking the Pencil, Paintbrush, Smooth, Blob Brush or Eraser tool (`tool.options {tool: "pencil"}`) opens a `freehandOptions` dialog. Its fields are `tool` and the options the tool -keeps: `fidelity` (screen pixels for the Pencil and Paintbrush, the same at any zoom; pt for Smooth and Blob Brush), `fill` (Pencil, Paintbrush), `keepSelected` (Pencil, +keeps: `fidelity` (screen pixels for the Pencil, Paintbrush and Smooth, the same at any zoom; pt for the Blob Brush), `fill` (Pencil, Paintbrush), `keepSelected` (Pencil, Paintbrush, Blob Brush; on by default for the Pencil and Paintbrush and off for the Blob Brush, and while it is off the path a stroke draws, continues or merges into is not selected afterwards), `mergeOnlySelection` (Blob Brush; off by default, and while it is on a stroke merges only with selected blobs), `closeWithin` and `editWithin` diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..2dfc3460e6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9368,6 +9368,11 @@ active the active artboard (the navigator's and the Artboards panel's). {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}} ``` +The Pencil's, the Paintbrush's and the Smooth tool's `fidelity` is in screen pixels: the tools send `path.freehand` and +`path.smoothRegion` the option divided by the zoom, so a stroke keeps as many points and a Smooth drag smooths as much +at any zoom. The Smooth tool's `fidelity` (0.05 to 100 px, 2.5 by default) is also the one an Alt smoothing drag with +the Pencil, Paintbrush or Blob Brush uses. The Blob Brush's `fidelity` is in points. + `tool.options {tool}` opens what double-clicking the tool's button opens (its params doc lists every tool), in the desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`): `diameter` and `pathLength` (pt), `opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and `largest` (%), `rays`, `rings`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..6f5898fe57 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1014,6 +1014,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. + Smooth Tool Options' Fidelity is in pixels, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=79) gives it: the Smooth tool, and an Alt smoothing drag with the Pencil, Paintbrush or Blob Brush, refits the path at Fidelity divided by the zoom, so a drag smooths a path as much on screen at 25 % as at 800 %. Reading the pixels as screen pixels is VectorCraft's own rule, as for the Pencil and the Paintbrush (#5729); the range (0.05 to 100 px) and the default (2.5 px) are VectorCraft's own values, and a saved Fidelity keeps its number (`crates/tools/src/draw2/gesture/tests.rs` test `smooth_fidelity_is_in_screen_pixels_at_any_zoom`, `crates/engine/src/tests/draw2.rs` test `smooth_region_refits_at_the_tolerance_sent_at_any_zoom`). With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. A shape just drawn stays adjustable with its tool: its Live Shape widgets drag, a press on its centre point (Show Center) moves the selection with the Selection tool's move arrow, snapping and Alt copy, and a press in the rotate zone just outside a bounding-box corner turns it about the centre with the Selection tool's rotate cursor (#5477; tests `a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corner` and, through the engine, `shapetools.rs`). A drag on one of its eight bounding-box handles scales it as the Selection tool's handles do, with their cursors and snapping, instead of drawing a new shape from the handle. A shape tool takes a press as a handle's only on the drawn handle square (VectorCraft's own rule: 3 screen points from its centre along each axis at the default Anchor Size, a half point more or less per step; the Selection tool takes 5), so a press just beside a handle still draws (tests `a_shape_tool_scales_the_selection_by_its_bounding_box_handles`, `a_shape_tool_draws_from_a_press_just_beside_a_handle` and `a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle`). The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). From aa5654ec2d575db0831adf8d582be02c5e13b52f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:54:52 -0800 Subject: [PATCH 03/27] Tests: Smooth Tool Options reports its Fidelity slider in px dialog_sliders_report_the_numbers_their_fields_show still expected the Smooth tool's Fidelity slider to read "0.25pt". The dialog shows that Fidelity in screen pixels, so the test expects "0.25px", and it now also checks that the Blob Brush's Fidelity slider reads in points. --- crates/ui-egui/src/dialogs/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index a7d62b6daf..14d88698aa 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2289,7 +2289,13 @@ fn dialog_sliders_report_the_numbers_their_fields_show() { app.run("tool.options", json!({"tool": "smooth"})).unwrap(); set(&mut app, "fidelity", json!(0.25)); let shown = tree(|ui| show(&mut app, ui.ctx())); - assert_eq!(slider_value(&shown, "Fidelity:"), v(0.25, (0.05, 20.0), "0.25pt")); + assert_eq!(slider_value(&shown, "Fidelity:"), v(0.25, (0.05, 20.0), "0.25px"), "the Smooth tool's Fidelity is on screen (#4671)"); + // The Blob Brush's Fidelity is in points. + app.ui.dialog = None; + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + set(&mut app, "fidelity", json!(2.5)); + let shown = tree(|ui| show(&mut app, ui.ctx())); + assert_eq!(slider_value(&shown, "Fidelity:"), v(2.5, (0.01, 20.0), "2.5pt")); app.ui.dialog = None; app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap(); app.run("ui.colorPicker", json!({})).unwrap(); From 2343e0e8b088ee29ffc3ff0ba7cce4faf8353419 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:37:22 -0800 Subject: [PATCH 04/27] Graphs: paint commands store a series' paint only from the row a mark shows paint.setFill, paint.setStroke, paint.none, Apply Fill to Stroke, Apply Stroke to Fill and a library gradient or pattern applied with library.use stored the topmost fill or stroke row of a graph series mark on the graph even when that row was hidden, and stored a stroke set on a Filled Lines band. When the graph was drawn again, every mark of the series took a paint that the edited mark never showed, and a band took the stroke color as its fill. These commands now follow the rules of the Appearance commands: - The graph stores the paint only when the row the command wrote is the topmost visible row of its kind, or when the command changed what the mark shows. With `item`, that is the named row. - A series that draws Filled Lines bands stores no stroke paint or weight; the band keeps its own outline. - A row without paint before and after the command keeps what the graph stores, so None on a marker without a stroke no longer removes the line's stored stroke. - When one command writes several marks of a series, the frontmost mark sets what the graph stores, whatever the order of the ids. - Apply Fill to Stroke and Apply Stroke to Fill on a mark whose row already has the paint store it, as paint.setFill does. A document without graph series marks among the edited objects skips the comparison of paints. Docs: docs/mcp.md and the params text of the paint commands. --- crates/engine/src/cmd/addons/color.rs | 22 ++- crates/engine/src/cmd/appearance.rs | 27 +++ crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/series.rs | 26 +-- crates/engine/src/cmd/graph/tests.rs | 232 +++++++++++++++++++++++++- crates/engine/src/cmd/paint.rs | 32 +++- docs/mcp.md | 10 +- 7 files changed, 307 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index ced10c9912..fa8182c4c2 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Apply Fill to Stroke", ["Object", "Plug-ins", "Color Effects"], None, - "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", + "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}", has_doc, |s, p| copy_paint(s, p, true) ), @@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { if !ids.is_empty() { let before = s.doc()?.doc.clone(); super::super::overprint::edit_counted(s, label, |d| { + // Each object with the row the paint went to, for the paint a graph stores for its series. + let mut written = Vec::new(); for id in &ids { let Some(node) = d.node_mut(*id) else { continue }; if let NodeKind::Text(t) = &mut node.kind { @@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) }; // A missing item reads as None: copying None onto it would add an item that // changes nothing. - if (skip_none && from.is_none()) || from == to { + if skip_none && from.is_none() { continue; } - if to_stroke { - restore_unpainted_stroke(ap, None, &from); - ap.set_stroke(from); - } else { - ap.set_fill(from); + if from != to { + if to_stroke { + restore_unpainted_stroke(ap, None, &from); + ap.set_stroke(from); + } else { + ap.set_fill(from); + } } + // A row that already had the paint counts as written with it, as for `paint.setFill`. + written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at))); } objects = super::super::appearance::count_changed(d, &before, &ids); // The graph keeps the paint its series marks and labels now have. - let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke); + let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written); let labels = super::super::graph::capture_label_styles(d, &ids, ©_run); Ok(objects + usize::from(series || labels)) })?; diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fe24e75ac4..1ffe2b4cbb 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -725,6 +725,33 @@ pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) } +/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke +/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote +/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint. +/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches several marks of one +/// series, the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint +/// changed. +pub(crate) fn capture_series_paint_writes( + before: &vectorcraft_doc::Document, + d: &mut vectorcraft_doc::Document, + ids: &[NodeId], + written: &[(NodeId, usize)], +) -> bool { + // Only objects directly in a series group can be marks: other edits skip the comparison of paints and the search of + // the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order, each with all of its rows. + marks.sort_by_cached_key(|id| d.index_path(*id)); + let mut rows = Vec::new(); + for id in &marks { + rows.extend(top_paint_changed(before, d, &[*id])); + let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?))); + rows.extend(painted_rows(d, shown.collect::>())); + } + super::graph::capture_series_top_paints(d, &rows) +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e2bb2b312c..2a170a30f4 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,7 +46,7 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_marks_in, series_object_opacity_differs, shown_paint, + capture_series_top_paints, paint_targets, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index a417a74015..95f8e99864 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -143,29 +143,6 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { out } -/// Copy the paint just applied to generated series art into that series' fill or stroke. Called -/// inside the same document edit, so the visible paint and future regeneration undo together. -/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour. -/// Returns whether a series' captured fill or stroke changed. -pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool { - let members = series_members(doc); - if members.is_empty() { - return false; - } - let captures: Vec<_> = ids - .iter() - .filter_map(|id| { - // Only a series' own marks set its paint, not the art of a marker design. - let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; - let node = doc.node(*id)?; - let paint = node.appearance.paint_at(None, fill)?.clone(); - let width = (!fill).then(|| node.appearance.stroke_width()); - Some((r.graph, r.index, fill, paint, width)) - }) - .collect(); - store_series_paints(doc, captures) -} - /// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint). /// With no such row: no paint. pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { @@ -178,7 +155,8 @@ pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) { /// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series /// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series' -/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its +/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as +/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its /// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series' /// captured fill or stroke changed. pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index fcb798c45e..9485f47ed2 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1263,7 +1263,7 @@ fn removing_a_filled_line_bands_stroke_leaves_its_color() { let (mut s, id, band) = band_graph(); let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap(); - assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); assert_eq!(redrawn_band(&mut s, id).0, was); } @@ -1733,6 +1733,236 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { assert_eq!(spec(&s, id).series_paints[0].fill, red); } +/// A column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill +/// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark. +fn hidden_top_rows() -> (Session, NodeId, NodeId) { + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (_, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap(); + let stroke = top_row(&s, marks[0], false); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap(); + (s, id, marks[0]) +} + +/// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row +/// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored. +#[test] +fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() { + let cases = [ + ("paint.setFill", json!({"color": "#0000ff"})), + ("paint.setFill", json!({"none": true})), + ("paint.setStroke", json!({"color": "#0000ff"})), + ("paint.setStroke", json!({"none": true})), + ("paint.none", json!({})), + ("addon.color.fillToStroke", json!({})), + ("addon.color.strokeToFill", json!({})), + ]; + every_case(cases, |(cmd, params)| { + let (mut s, id, mark) = hidden_top_rows(); + let was = spec(&s, id).series_paints; + let red = was[0].fill.clone().unwrap(); + assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0))); + let mut p = params.clone(); + if *cmd == "paint.none" { + s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); + } else { + p["ids"] = json!([mark.0]); + } + let before = s.doc().unwrap().doc.node(mark).unwrap().clone(); + s.execute(cmd, &p).unwrap(); + assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row"); + assert_eq!(spec(&s, id).series_paints, was); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0)); + }); +} + +/// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series +/// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one. +#[test] +fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() { + let (mut s, id, mark) = hidden_top_rows(); + let (lower, _) = fill_rows(&s, mark); + s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned(); + assert!(blue.as_ref().is_some_and(|p| !p.is_none())); + assert_eq!(spec(&s, id).series_paints[0].fill, blue); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(spec(&s, id).series_paints[0].fill, blue); + + // The first mark has a green fill over another one, and the series stores the red fill of the second mark. + let (mut s, id, _, marks) = column_marks(); + s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap(); + let (lower, upper) = fill_rows(&s, marks[0]); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap(); + let red = Some(fill_of(&s, marks[1])); + assert_ne!(red, Some(fill_of(&s, marks[0]))); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap(); + assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0)); +} + +/// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series +/// stores it from a visible row, and keeps its stored paint when that row is hidden. +#[test] +fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() { + for hidden in [false, true] { + let (mut s, id, mark) = hidden_top_rows(); + if !hidden { + s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap(); + } + let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string(); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone(); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap(); + s.execute("select.set", &json!({"ids": [mark.0]})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap(); + let fill = fill_of(&s, mark); + assert!(matches!(fill, Paint::Gradient(_)), "{hidden}"); + let now = spec(&s, id).series_paints; + if hidden { + assert_eq!(now, was); + } else { + assert_eq!(now[0].fill, Some(fill)); + } + } +} + +/// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another +/// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and +/// the series stores the mark's paint for the kind of row the command wrote, and only for that kind. +#[test] +fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() { + let cases = [ + ("paint.setFill", json!({"color": "#ff0000"}), true), + ("paint.setStroke", json!({"color": "#ff0000"}), false), + ("addon.color.fillToStroke", json!({}), false), + ("addon.color.strokeToFill", json!({}), true), + ("addon.color.fillToStroke", json!({"skipNone": true}), false), + ]; + every_case(cases, |(cmd, params, fill)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); + } + let was = spec(&s, id).series_paints; + let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1])); + assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0))); + let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let mut p = params.clone(); + p["ids"] = json!([marks[0].0]); + s.execute(cmd, &p).unwrap(); + assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was"); + let now = spec(&s, id).series_paints; + let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) }; + assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); + }); +} + +/// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every +/// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what +/// the series stores. +#[test] +fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); + let blue = fill_of(&s, marks[0]); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints; + assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.clone()))); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| (&m.0, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0)); + + let (mut s, id, marks) = stored_red_stroke(); + let red = spec(&s, id).series_paints[0].stroke.clone(); + s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, red); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap(); + let was = spec(&s, id).series_paints; + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints, was); +} + +/// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the +/// graph stores, whatever order the ids are given in and whatever the marks' ids are. +#[test] +fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() { + every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| { + let (mut s, id, _, marks) = column_marks(); + // The mark made last goes to the back, so the marks' ids are not in paint order. + s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap(); + s.execute("object.arrange.sendToBack", &json!({})).unwrap(); + let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect(); + assert_eq!(drawn, [marks[2], marks[0], marks[1]]); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}"); + }); + + // The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's. + let (mut s, id, marks) = stored_red_stroke(); + s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap(); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap(); + assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none()); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); +} + +/// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its +/// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored. +#[test] +fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() { + every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| { + let (mut s, id, band) = band_graph(); + let was = series_parts(&s, id, 0)[0].appearance.fill_paint(); + let mut p = params.clone(); + p["ids"] = json!([band.0]); + s.execute(cmd, &p).unwrap(); + assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none()); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none())); + assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); + }); + let (mut s, id, band) = band_graph(); + s.execute("paint.setFill", &json!({"ids": [band.0], "color": "#0000ff"})).unwrap(); + let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint(); + assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); +} + +/// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series +/// stores. On the line itself it stores the empty stroke. +#[test] +fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() { + let (mut s, id, line, marker) = line_and_marker(); + s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap(); + let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone())); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red); + + let line = line_parts(&s, id).0.id; + s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); +} + /// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 266422fc02..335619b4a4 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern)", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md)", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -64,10 +64,18 @@ pub fn specs() -> Vec { Ok(json!({ "fillActive": s.fill_active })) } ), - cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| { - let f = s.fill_active; - set_paint(s, &json!({"none": true}), f) - }), + cmd!( + "paint.none", + "None", + [], + Some("/"), + "{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}", + has_doc, + |s, _| { + let f = s.fill_active; + set_paint(s, &json!({"none": true}), f) + } + ), cmd!( "transparency.set", "Transparency", @@ -523,6 +531,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) vec![] }; let label = if fill { "Fill Color" } else { "Stroke Color" }; + // Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series + // (a series mark is a path, so it always has a row here). + let written = std::cell::RefCell::new(Vec::new()); edit_items_then( s, &ids, @@ -581,10 +592,15 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) if !fill { super::object::snap_if_aligned(n); } + let at = match index { + Some(_) => n.appearance.item_of_kind(index, fill), + None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill), + }; + written.borrow_mut().extend(at.map(|at| (n.id, at))); Ok(()) }, - |_, d, changed| { - let series = super::graph::capture_series_paints(d, changed, fill); + |before, d, changed| { + let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow()); let labels = super::graph::capture_label_styles(d, changed, &|st| { if fill { st.fill = paint.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index f2945064e1..54acc208cf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -756,8 +756,14 @@ outline, so these commands do not store a stroke paint or weight for a series th `paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph designs keep their own paint. A row that had no paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, -`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind -even when it is hidden, and store a stroke set on a band. +`paint.setStroke`, `paint.none`, `addon.color.fillToStroke` and `addon.color.strokeToFill` follow the same rules, and so +does `library.use` when it applies a gradient or pattern. They write the topmost row of the kind; `paint.setFill` and +`paint.setStroke` write the row `item` names instead, and without ids the three `paint.*` commands write the active +row of the Appearance panel when it is of that kind. The graph stores the paint only when the written row is the +topmost visible one of its kind, or when the command changes what the mark shows. A paint written to a hidden row or to +a row under the topmost visible one is not stored, and the mark loses it when the graph is drawn again. A mark whose row +already has the paint counts as written, so its paint replaces the one another mark of the series left on the graph. +When one of these commands writes several paths of a series, the frontmost of them sets what the graph stores. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. From c1ba246bb390ffddce48b50e73dbce079201b42e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:37:35 -0800 Subject: [PATCH 05/27] Graphs: a paint command that leaves a series' stored paint as it was records no undo step The graph compares the paints a command leaves on it with the paints it stored before the command, not with the value before each mark's capture. Apply Fill to Stroke on two marks whose strokes already had their fills stored the back mark's stroke and then the front mark's again, and recorded an undo step with nothing changed. The same comparison applies to the opacity and blend mode a series keeps. docs/mcp.md states that only the topmost visible row's paint is stored, and the Graphs line of docs/target-app-parity.md states that a series that draws Filled Lines bands keeps no stroke paint or weight. --- crates/engine/src/cmd/graph/series.rs | 44 ++++++++++++++++++--------- crates/engine/src/cmd/graph/tests.rs | 21 +++++++++++++ docs/mcp.md | 7 +++-- docs/target-app-parity.md | 2 +- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 95f8e99864..413726a0b1 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -209,13 +209,32 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { } } -/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a -/// series' captured fill or stroke changed. -fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { +/// Each graph's stored series paints before an edit's first capture into it. A later capture can put back what an earlier +/// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]). +type SeriesPaintsBefore = HashMap>; + +/// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the +/// captures added past the old end and left empty are removed again, so they do not count as a change. +fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool { let mut changed = false; + for (graph, was) in before { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let empty = vectorcraft_doc::SeriesPaint::default(); + if spec.series_paints.get(was.len()..).is_some_and(|added| added.iter().all(|slot| *slot == empty)) { + spec.series_paints.truncate(was.len()); + } + changed |= spec.series_paints != was; + } + changed +} + +/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// series' captured fill or stroke differs from what the graph stored before the first capture. +fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { + let mut before = SeriesPaintsBefore::new(); for (graph, index, fill, paint, width) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); @@ -227,9 +246,8 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P slot.stroke = Some(paint); slot.stroke_width = width; } - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just @@ -256,14 +274,14 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); - let mut changed = false; + let mut before = SeriesPaintsBefore::new(); for (graph, index, fill, kept, blend) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); @@ -274,32 +292,30 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } else { (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity /// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object /// opacity or blend mode changed. pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { - let mut changed = false; + let mut before = SeriesPaintsBefore::new(); for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } - let was = spec.series_paints.clone(); + before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). if spec.series_paints.len() <= index { spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; (slot.opacity, slot.blend) = (opacity, blend); - changed |= spec.series_paints != was; } - changed + settle_series_paints(doc, before) } /// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 49a77343ad..85cf954e10 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2043,6 +2043,27 @@ fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint( assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None)); } +/// Apply Fill to Stroke on two marks whose strokes already have their fills, while the series stores the front mark's +/// stroke: the back mark's stroke is taken first and the front mark's after it, so the graph ends up storing what it +/// stored before. The document and its undo history stay as they are. +#[test] +fn a_paint_command_that_leaves_a_series_stored_paint_as_it_was_records_no_undo_step() { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap(); + } + let drawn: Vec = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..2].contains(m)).collect(); + assert_eq!(drawn, [marks[0], marks[1]], "the second mark is in front"); + let was = s.doc().unwrap().doc.clone(); + let blue = fill_of(&s, marks[1]); + assert_eq!(spec(&s, id).series_paints[0].stroke, Some(blue)); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("addon.color.fillToStroke", &json!({"ids": [marks[1].0, marks[0].0]})).unwrap(); + assert_eq!(s.doc().unwrap().doc, was); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + /// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its /// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 55dac76b61..a9f52b6a6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -759,9 +759,10 @@ paint before the command and has none after it does not change the paint the gra `paint.setStroke`, `paint.none`, `addon.color.fillToStroke` and `addon.color.strokeToFill` follow the same rules, and so does `library.use` when it applies a gradient or pattern. They write the topmost row of the kind; `paint.setFill` and `paint.setStroke` write the row `item` names instead, and without ids the three `paint.*` commands write the active -row of the Appearance panel when it is of that kind. The graph stores the paint only when the written row is the -topmost visible one of its kind, or when the command changes what the mark shows. A paint written to a hidden row or to -a row under the topmost visible one is not stored, and the mark loses it when the graph is drawn again. A mark whose row +row of the Appearance panel when it is of that kind. The graph stores only the paint of the topmost visible row of the +kind, and only when the command wrote that row or changed its paint or weight. A paint written to a hidden row or to a +row under the topmost visible one is not stored, also when that row shows through a translucent row above it, and the +mark loses it when the graph is drawn again. A mark whose row already has the paint counts as written, so its paint replaces the one another mark of the series left on the graph. When one of these commands writes several paths of a series, the frontmost of them sets what the graph stores. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 88dc2468f2..c0bd09ffe9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1070,7 +1070,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. From 1d35e00981e3a42959947ab7c8225eae41477f11 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:34:44 -0800 Subject: [PATCH 06/27] Live Paint and Shape Builder: tool.setOption refuses invalid options tool.setOption for the Live Paint Bucket, the Live Paint Selection tool and the Shape Builder used to keep the old value when a switch was not true or false, a color could not be read or a width or gap length was not a number, and it set the other values sent with it. It also took the RGB of a color with an alpha part, and the Shape Builder took any `pickColorFrom` other than `swatches` as `artwork` and any `selection` other than `straightLine` as `freeform`. The command now checks every value before it sets any, whether the tool is chosen or not: a switch must be true or false, a highlight color must be an RGB color or a layer color's name without an alpha part, a width or gap length must be a number, `pickColorFrom` and `selection` must be one of their names, and an option the tool does not have is an error. The error names the option, and nothing is set or saved. A width outside 1 to 20 and a gap length outside 0 to 72 are still clamped, and saved settings with an invalid value still load. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/tests/build.rs | 138 +++++++++++++++++++++++++ crates/engine/src/tooling.rs | 10 +- crates/tools/src/builder.rs | 35 ++++++- crates/tools/src/builder/live_paint.rs | 36 +++++++ crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/mcp.md | 18 +++- docs/target-app-parity.md | 2 + 29 files changed, 300 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 6eeace1073..a3987ee4b0 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3087,6 +3087,144 @@ fn live_paint_bucket_tool_fills_face() { assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +/// `tool.setOption` refuses an invalid value for the Live Paint tools, naming its key, whether the +/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or +/// false, a color that is not an RGB color (an alpha part included), a width that is not a number +/// and an option the tool does not have. Nothing is set, also from the valid values sent with it, +/// and the saved settings stay as they were. +#[test] +fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() { + let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)]; + let widths = [json!("wide"), json!(null), json!(true)]; + let switches = [json!("true"), json!(null), json!(1)]; + for (tool, keys) in + [("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])] + { + let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect(); + bad.extend(colors.iter().map(|v| ("color", v.clone()))); + bad.extend(widths.iter().map(|v| ("width", v.clone()))); + bad.push(("colour", json!("#00ff00"))); + for active in [false, true] { + let mut s = session(); + if active { + s.select_tool(tool, ViewInfo::default()).unwrap(); + } + let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone()); + for (key, v) in &bad { + let mut values = serde_json::Map::new(); + values.insert("width".into(), json!(9)); + values.insert(key.to_string(), v.clone()); + for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] { + let e = s.set_tool_option_cmd(&p).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{tool} {p}: {e}"); + assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{tool} {p}"); + } + } + // A `key` left without a `value` is an invalid value too. + assert!(s.set_tool_option_cmd(&json!({"tool": tool, "key": "color"})).is_err()); + s.select_tool(tool, ViewInfo::default()).unwrap(); + assert_eq!(s.tool_options_of(tool), options, "{tool}"); + } + } +} + +/// The Live Paint tools' options take each color form they took before, and a width out of range +/// is clamped to 1–20 rather than refused; the values are saved with the preferences. +#[test] +fn live_paint_tool_options_take_rgb_colors_and_clamp_the_width() { + let mut s = session(); + for tool in ["livePaintBucket", "livePaintSelection"] { + for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] { + let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "color", "value": color})).unwrap(); + assert_eq!(o["color"], hex, "{tool} {color}"); + } + for (width, set) in [(100.0, 20.0), (0.0, 1.0), (-3.0, 1.0), (7.5, 7.5)] { + let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"width": width}})).unwrap(); + assert_eq!(o["width"], set, "{tool} {width}"); + } + assert_eq!(s.prefs.tool_settings[tool]["width"], 7.5); + assert_eq!(s.prefs.tool_settings[tool]["color"], "#ffffff"); + } + let o = s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"paintFills": false, "highlight": false}})).unwrap(); + assert_eq!((&o["paintFills"], &o["highlight"]), (&json!(false), &json!(false))); +} + +/// Saved Live Paint settings that hold an invalid value still load: the tool keeps its default for +/// that option and takes the valid ones. +#[test] +fn live_paint_tools_load_saved_settings_with_invalid_values() { + let mut s = session(); + let saved = json!({"paintFills": "yes", "highlight": false, "width": "wide", "color": "nope", "paintStrokes": true}); + s.prefs.tool_settings.insert("livePaintBucket".into(), saved.as_object().unwrap().clone()); + s.select_tool("livePaintBucket", ViewInfo::default()).unwrap(); + let o = s.tool_options_of("livePaintBucket"); + assert_eq!( + (&o["paintFills"], &o["paintStrokes"], &o["highlight"], &o["width"], &o["color"]), + (&json!(true), &json!(true), &json!(false), &json!(4.0), &json!("#ff2a2a")) + ); +} + +/// `tool.setOption` refuses an invalid value for the Shape Builder, naming its key, whether the +/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or +/// false, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not +/// a number, a `highlightColor` that is not an RGB color (an alpha part included) and an option the +/// tool does not have. Nothing is set, also from the valid values sent with it, and the saved +/// settings stay as they were. +#[test] +fn shape_builder_tool_options_refuse_invalid_values_and_change_nothing() { + let tool = "shapeBuilder"; + let switches = ["openFilledAsClosed", "cursorSwatchPreview", "highlightFill", "gapDetection", "clickStrokeSplitsPath", "highlightStroke"]; + let mut bad: Vec<(&str, Value)> = switches.iter().flat_map(|k| [json!("true"), json!(null), json!(1)].map(move |v| (*k, v))).collect(); + bad.extend([json!("Swatches"), json!(true), json!(null), json!("")].map(|v| ("pickColorFrom", v))); + bad.extend([json!("straight"), json!(1), json!(null)].map(|v| ("selection", v))); + bad.extend([json!("wide"), json!(null), json!(true)].map(|v| ("gapLength", v))); + let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)]; + bad.extend(colors.map(|v| ("highlightColor", v))); + bad.push(("color", json!("#00ff00"))); + for active in [false, true] { + let mut s = session(); + if active { + s.select_tool(tool, ViewInfo::default()).unwrap(); + } + let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone()); + for (key, v) in &bad { + let mut values = serde_json::Map::new(); + values.insert("gapLength".into(), json!(9)); + values.insert(key.to_string(), v.clone()); + for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] { + let e = s.set_tool_option_cmd(&p).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{p}: {e}"); + assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{p}"); + } + } + s.select_tool(tool, ViewInfo::default()).unwrap(); + assert_eq!(s.tool_options_of(tool), options); + } +} + +/// The Shape Builder's options take each value they took before: the names of `pickColorFrom` and +/// `selection`, each RGB color form, and a gap length out of range clamped rather than refused. +#[test] +fn shape_builder_tool_options_take_valid_values_and_clamp_the_gap() { + let mut s = session(); + let tool = "shapeBuilder"; + for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] { + let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "highlightColor", "value": color})).unwrap(); + assert_eq!(o["highlightColor"], hex, "{color}"); + } + for (gap, set) in [(1000.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP), (-3.0, 0.0), (7.5, 7.5)] { + let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"gapLength": gap}})).unwrap(); + assert_eq!(o["gapLength"], set, "{gap}"); + } + let o = s + .set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches", "selection": "straightLine", "gapDetection": true}})) + .unwrap(); + assert_eq!((&o["pickColorFrom"], &o["selection"], &o["gapDetection"]), (&json!("swatches"), &json!("straightLine"), &json!(true))); + let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "artwork", "selection": "freeform"}})).unwrap(); + assert_eq!((&o["pickColorFrom"], &o["selection"]), (&json!("artwork"), &json!("freeform"))); + assert_eq!(s.prefs.tool_settings[tool]["gapLength"], 7.5); +} + #[test] fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 0452251a09..c052edb765 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1305,14 +1305,12 @@ impl Session { { return Err(format!("unknown tool `{t}`")); } - // The active tool's merge being computed is applied with the options it was started with. - if tool.is_none_or(|t| t == self.tool.id()) { - self.finish_tool_edit_later().map_err(|e| e.to_string())?; - } let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); if let Some(k) = p.get("key").and_then(Value::as_str) { values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); } + // Every value is checked before any is set, so an invalid one changes nothing. + vectorcraft_tools::builder::check_builder_options(tool.unwrap_or(self.tool.id()), &values)?; // The Place queue is read later, by a click outside any automation scope: its files must // be readable now, under the roots of whoever sets it. if let Some(entries) = values.get("queue").and_then(|q| q.get("entries")).and_then(Value::as_array) { @@ -1330,6 +1328,10 @@ impl Session { return Err("Unknown dimension mode".into()); } } + // The active tool's merge being computed is applied with the options it was started with. + if tool.is_none_or(|t| t == self.tool.id()) { + self.finish_tool_edit_later().map_err(|e| e.to_string())?; + } Ok(self.set_tool_options(tool, &values)) } diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 1c5a895161..2190d34180 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -35,11 +35,11 @@ mod map; use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache}; #[cfg(test)] use live_paint::LpHover; -use live_paint::lp_color; pub use live_paint::{ LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool, }; +use live_paint::{check_live_paint_options, lp_color, lp_rgb_color}; pub use lookup::{ EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, @@ -777,6 +777,39 @@ impl Tool for ShapeBuilderTool { } } +/// Why `tool.setOption` cannot give the builder-family tool `tool` the options `values`, checked +/// before any of them is set. For `shapeBuilder`: an option the tool does not have, a switch that +/// is not true or false, a `pickColorFrom` other than `artwork` or `swatches`, a `selection` other +/// than `freeform` or `straightLine`, a `highlightColor` that is not an RGB color without an alpha +/// part and a `gapLength` that is not a finite number are errors. A finite gap length out of 0 to +/// [`SHAPE_BUILDER_MAX_GAP`] is not an error: the tool clamps it. The Live Paint tools are checked +/// by [`check_live_paint_options`]; Ok for every other tool. Saved settings go to the tools' +/// `set_option` instead, which skips a value it cannot read. +pub fn check_builder_options(tool: &str, values: &serde_json::Map) -> Result<(), String> { + if tool != "shapeBuilder" { + return check_live_paint_options(tool, values); + } + let one_of = |key: &str, v: &Value, names: &[&str]| { + (!v.as_str().is_some_and(|s| names.contains(&s))).then(|| format!("`{key}` must be one of {}", names.join(", "))) + }; + for (key, v) in values { + let error = match key.as_str() { + "openFilledAsClosed" | "cursorSwatchPreview" | "highlightFill" | "gapDetection" | "clickStrokeSplitsPath" | "highlightStroke" => { + (!v.is_boolean()).then(|| format!("`{key}` must be true or false")) + } + "pickColorFrom" => one_of(key, v, &["artwork", "swatches"]), + "selection" => one_of(key, v, &["freeform", "straightLine"]), + "gapLength" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")), + "highlightColor" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")), + _ => Some(format!("unknown Shape Builder option `{key}`")), + }; + if let Some(e) = error { + return Err(e); + } + } + Ok(()) +} + /// Create a builder-family tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e2d6c9f77e..6b744e4435 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -594,6 +594,42 @@ pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> { Some([r, g, b]) } +/// Whether `v` is a highlight color [`lp_color`] reads without an alpha part: `#rrggbbaa` (with +/// or without `#`) is not one. +pub(super) fn lp_rgb_color(v: &Value) -> bool { + let Some(s) = v.as_str() else { return false }; + let digits = s.trim().trim_start_matches('#'); + let alpha = digits.len() == 8 && digits.chars().all(|c| c.is_ascii_hexdigit()); + !alpha && lp_color(v).is_some() +} + +/// Why `tool.setOption` cannot give the Live Paint tool `tool` (`livePaintBucket` or +/// `livePaintSelection`) the options `values`, checked before any of them is set: an option the +/// tool does not have, a switch that is not true or false, a `color` that is not an RGB color +/// ([`lp_rgb_color`]) and a `width` that is not a finite number are errors. A finite width out of +/// [`LP_BUCKET_WIDTH_RANGE`] or [`LP_SELECT_WIDTH_RANGE`] is not an error: the tool clamps it. +/// Ok for every other tool. Saved settings go to the tools' `set_option` instead, which skips a +/// value it cannot read. +pub fn check_live_paint_options(tool: &str, values: &serde_json::Map) -> Result<(), String> { + let switches: &[&str] = match tool { + "livePaintBucket" => &["paintFills", "paintStrokes", "highlight"], + "livePaintSelection" => &["selectFills", "selectStrokes"], + _ => return Ok(()), + }; + for (key, v) in values { + let error = match key.as_str() { + "color" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")), + "width" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")), + k if switches.contains(&k) => (!v.is_boolean()).then(|| format!("`{key}` must be true or false")), + _ => Some(format!("unknown Live Paint option `{key}`")), + }; + if let Some(e) = error { + return Err(e); + } + } + Ok(()) +} + impl Tool for LivePaintSelectionTool { fn id(&self) -> &'static str { "livePaintSelection" diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b0159ebc14..9da7205df7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5248,6 +5248,9 @@ digits Malayalam Malaiàlam @msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` ha de ser [x, y, width, height]: quatre nombres finits, amb amplada i alçada no negatives @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` ha de ser `auto` o un codi d’idioma com `en` o `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` ha de ser un color #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` ha de ser un color #rrggbb o el nom d’un color de capa +@msg unknown Live Paint option `{key}` opció de pintura interactiva desconeguda `{key}` +@msg unknown Shape Builder option `{key}` opció del constructor de formes desconeguda `{key}` @msg `{key}` must be a #rrggbb colour `{key}` ha de ser un color #rrggbb @msg `{key}` must be a number `{key}` ha de ser un nombre @msg `{key}` must be a number not below 0 `{key}` ha de ser un nombre no inferior a 0 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 27b2d32771..d91c01fe43 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4737,6 +4737,9 @@ imageTrace Outline Obrys @msg `{keep}` is not a side of this axis `{keep}` není strana této osy @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` musí být `auto` nebo kód jazyka, například `en` nebo `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` musí být barva #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` musí být barva #rrggbb nebo název barvy vrstvy +@msg unknown Live Paint option `{key}` neznámá možnost živé malby `{key}` +@msg unknown Shape Builder option `{key}` neznámá možnost tvorby tvarů `{key}` @msg `{key}` must be a #rrggbb colour `{key}` musí být barva #rrggbb @msg `{key}` must be a number `{key}` musí být číslo @msg `{key}` must be a number, not {v} `{key}` musí být číslo, ne {v} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b6cd3e8a6..ef3a24e09b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4351,6 +4351,9 @@ theme Appearance Erscheinungsbild @msg `{keep}` is not a side of this axis `{keep}` ist keine Seite dieser Achse @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` muss `auto` oder ein Sprachcode wie `en` oder `zh-hant` sein @msg `{key}` must be a #rrggbb color `{key}` muss eine Farbe der Form #rrggbb sein +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` muss eine Farbe der Form #rrggbb oder der Name einer Ebenenfarbe sein +@msg unknown Live Paint option `{key}` unbekannte Option für Interaktiv malen `{key}` +@msg unknown Shape Builder option `{key}` unbekannte Option für Formerstellung `{key}` @msg `{key}` must be a #rrggbb colour `{key}` muss eine Farbe der Form #rrggbb sein @msg `{key}` must be a number `{key}` muss eine Zahl sein @msg `{key}` must be a number, not {v} `{key}` muss eine Zahl sein, nicht {v} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8620169f21..e1a00ef2ee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2508,6 +2508,9 @@ axis Both Και τα δύο @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 color Το `{key}` πρέπει να είναι χρώμα #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name Το `{key}` πρέπει να είναι χρώμα #rrggbb ή όνομα χρώματος επιπέδου +@msg unknown Live Paint option `{key}` άγνωστη επιλογή δυναμικής βαφής `{key}` +@msg unknown Shape Builder option `{key}` άγνωστη επιλογή δημιουργίας σχημάτων `{key}` @msg `{key}` must be a #rrggbb colour Το `{key}` πρέπει να είναι χρώμα #rrggbb @msg `{key}` must be a number Το `{key}` πρέπει να είναι αριθμός @msg `{key}` must be a number, not {v} Το `{key}` πρέπει να είναι αριθμός, όχι {v} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a82db20a99..69c2bd21e3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4428,6 +4428,9 @@ axis Both Ambos @msg `{keep}` is not a side of this axis `{keep}` no es un lado de este eje @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` debe ser `auto` o un código de idioma como `en` o `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` debe ser un color #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` debe ser un color #rrggbb o el nombre de un color de capa +@msg unknown Live Paint option `{key}` opción de pintura interactiva desconocida `{key}` +@msg unknown Shape Builder option `{key}` opción del generador de formas desconocida `{key}` @msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb @msg `{key}` must be a number `{key}` debe ser un número @msg `{key}` must be a number, not {v} `{key}` debe ser un número, no {v} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index db90a36316..4c1b35c9e2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4651,6 +4651,9 @@ imageTrace Outline Ääriviiva @msg `{keep}` is not a side of this axis `{keep}` ei ole tämän akselin puoli @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` on oltava `auto` tai kielikoodi, kuten `en` tai `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` on oltava #rrggbb-väri +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` on oltava #rrggbb-väri tai tason värin nimi +@msg unknown Live Paint option `{key}` tuntematon elävän maalauksen valinta `{key}` +@msg unknown Shape Builder option `{key}` tuntematon muodonmuodostimen valinta `{key}` @msg `{key}` must be a #rrggbb colour `{key}` on oltava #rrggbb-väri @msg `{key}` must be a number `{key}` on oltava luku @msg `{key}` must be a number, not {v} `{key}` on oltava luku, ei {v} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 938e43dd48..2785b2fc97 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4417,6 +4417,9 @@ axis Both Les deux @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 color `{key}` doit être une couleur #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` doit être une couleur #rrggbb ou le nom d’une couleur de calque +@msg unknown Live Paint option `{key}` option de peinture dynamique inconnue `{key}` +@msg unknown Shape Builder option `{key}` option du concepteur de forme inconnue `{key}` @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 number, not {v} `{key}` doit être un nombre, pas {v} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0eacf9629b..09fdaba4ec 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5894,6 +5894,9 @@ antiAlias Sharp शार्प @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` को `auto` या `en` या `zh-hant` जैसे भाषा कोड होना चाहिए। @msg `{key}` must be 0..{max} `{key}` होना चाहिए 0.{max} @msg `{key}` must be a #rrggbb color `{key}` एक #rrggbb रंग होना चाहिए +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` एक #rrggbb रंग या लेयर रंग का नाम होना चाहिए +@msg unknown Live Paint option `{key}` अज्ञात लाइव पेंट ऑप्शन `{key}` +@msg unknown Shape Builder option `{key}` अज्ञात शेप बिल्डर ऑप्शन `{key}` @msg `{key}` must be a #rrggbb colour `{key}` एक #rrggbb रंग होना चाहिए @msg `{key}` must be a non-negative integer `{key}` को एक गैर-नकारात्मक पूर्णांक होना चाहिए @msg `{key}` must be a number `{key}` एक संख्या होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3dadc4d3b6..f01e9950e5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4860,6 +4860,9 @@ imageTrace Outline Körvonal @msg `{keep}` is not a side of this axis `{keep}` nem ennek a tengelynek az oldala @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}`: `auto` vagy nyelvkód kell, például `en` vagy `zh-hant` @msg `{key}` must be a #rrggbb color `{key}`: #rrggbb szín kell +@msg `{key}` must be a #rrggbb color or a layer color name `{key}`: #rrggbb szín vagy rétegszín neve kell +@msg unknown Live Paint option `{key}` ismeretlen élő festés beállítás: `{key}` +@msg unknown Shape Builder option `{key}` ismeretlen alakzatépítő beállítás: `{key}` @msg `{key}` must be a #rrggbb colour `{key}`: #rrggbb szín kell @msg `{key}` must be a number `{key}`: szám kell @msg `{key}` must be a number, not {v} `{key}`: szám kell, ez nem az: {v} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 91d1e24bf7..0bd4b85e66 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4480,6 +4480,9 @@ axis Both Entrambi @msg `{keep}` is not a side of this axis `{keep}` non è un lato di questo asse @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` deve essere `auto` o un codice lingua come `en` o `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` deve essere un colore #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` deve essere un colore #rrggbb o il nome di un colore del livello +@msg unknown Live Paint option `{key}` opzione di pittura dinamica sconosciuta `{key}` +@msg unknown Shape Builder option `{key}` opzione del generatore forme sconosciuta `{key}` @msg `{key}` must be a #rrggbb colour `{key}` deve essere un colore #rrggbb @msg `{key}` must be a number `{key}` deve essere un numero @msg `{key}` must be a number, not {v} `{key}` deve essere un numero, non {v} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 628d1ef3de..2dd63dc774 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4590,6 +4590,9 @@ swatch Bright Violet ビビッドバイオレット @msg installing from a path is not available on the web; pass the module as `dataBase64` Web 版ではファイルパスからはインストールできないため、モジュールは `dataBase64` で渡してください @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` は `auto` か、`en` や `zh-hant` などの言語コードにしてください @msg `{key}` must be a #rrggbb color `{key}` は #rrggbb 形式のカラーにしてください +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` は #rrggbb 形式のカラーかレイヤーカラー名にしてください +@msg unknown Live Paint option `{key}` 不明なライブペイントオプション `{key}` +@msg unknown Shape Builder option `{key}` 不明なシェイプ形成ツールオプション `{key}` @msg `{key}` must be a #rrggbb colour `{key}` は #rrggbb 形式のカラーにしてください @msg `{key}` must be a string `{key}` は文字列にしてください @msg `{key}` must be a profile name or null `{key}` はプロファイル名または null にしてください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index aa2c27c296..4bee58c53c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6081,6 +6081,9 @@ imageTrace Outline კონტური @msg byColor can't regroup a trace with a live Pathfinder effect: expand without byColor byColor ვერ გადააჯგუფებს ტრასირებას აქტიური Pathfinder ეფექტით: გააფართოვეთ byColor-ის გარეშე @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ბიბლიოთეკის პანელი არ არის (იხ. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` უნდა იყოს #rrggbb ფერი +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` უნდა იყოს #rrggbb ფერი ან ფენის ფერის სახელი +@msg unknown Live Paint option `{key}` ცოცხალი შეღებვის უცნობი პარამეტრი `{key}` +@msg unknown Shape Builder option `{key}` ფიგურების კონსტრუქტორის უცნობი პარამეტრი `{key}` @msg a key object sits on that point ამ წერტილზე საკვანძო ობიექტი მდებარეობს @msg a linked image can't become a gradient mesh: embed it first დაკავშირებული გამოსახულება გრადიენტულ ბადედ ვერ გადაიქცევა: ჯერ ჩააშენეთ ის @msg a mask has more than one art block ნიღაბს ერთზე მეტი ნახატის ბლოკი აქვს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5c2ac8d1..74ef2aa110 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4517,6 +4517,9 @@ theme Appearance 외관 @msg installing from a path is not available on the web; pass the module as `dataBase64` 웹에서는 경로로 설치할 수 없습니다. 모듈을 `dataBase64`로 전달하세요 @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}`은(는) `auto` 또는 `en`, `zh-hant` 같은 언어 코드여야 합니다 @msg `{key}` must be a #rrggbb color `{key}`은(는) #rrggbb 색상이어야 합니다 +@msg `{key}` must be a #rrggbb color or a layer color name `{key}`은(는) #rrggbb 색상이나 레이어 색상 이름이어야 합니다 +@msg unknown Live Paint option `{key}` 알 수 없는 라이브 페인트 옵션 `{key}` +@msg unknown Shape Builder option `{key}` 알 수 없는 모양 빌더 옵션 `{key}` @msg `{key}` must be a #rrggbb colour `{key}`은(는) #rrggbb 색상이어야 합니다 @msg `{key}` must be a string `{key}`은(는) 문자열이어야 합니다 @msg `{key}` must be a profile name or null `{key}`은(는) 프로파일 이름 또는 null이어야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d46d7e8e87..5d1a728e8f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6457,6 +6457,9 @@ swatch Bright Violet Felviolet @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Opslaan schrijft {_1}, niet {_2}: gebruik file.saveAsTemplate om het te schrijven @msg `{key}` must be a #rrggbb color `{key}` moet een #rrggbb-kleur zijn +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` moet een #rrggbb-kleur of de naam van een laagkleur zijn +@msg unknown Live Paint option `{key}` onbekende Live Paint-optie `{key}` +@msg unknown Shape Builder option `{key}` onbekende Vormopbouw-optie `{key}` @msg a key object sits on that point op dat punt ligt een sleutelobject @msg a linked image can't become a gradient mesh: embed it first een gekoppelde afbeelding kan geen verloopraster worden: sluit deze eerst in @msg a mask has more than one art block een masker heeft meer dan één illustratieblok diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 38d5e2f497..b5d92db24f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4561,6 +4561,9 @@ imageTrace Outline Kontur @msg `{keep}` is not a side of this axis `{keep}` nie jest stroną tej osi @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` musi mieć wartość `auto` lub kod języka, np. `en` albo `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` musi być kolorem #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` musi być kolorem #rrggbb lub nazwą koloru warstwy +@msg unknown Live Paint option `{key}` nieznana opcja aktywnego malowania `{key}` +@msg unknown Shape Builder option `{key}` nieznana opcja kreatora kształtów `{key}` @msg `{key}` must be a #rrggbb colour `{key}` musi być kolorem #rrggbb @msg `{key}` must be a number `{key}` musi być liczbą @msg `{key}` must be a number, not {v} `{key}` musi być liczbą, nie {v} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2a45bc8e68..40ad58a1d5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3896,6 +3896,9 @@ axis Both Ambos @msg `{keep}` is not a side of this axis `{keep}` não é um lado deste eixo @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` deve ser `auto` ou um código de idioma como `en` ou `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` deve ser uma cor #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` deve ser uma cor #rrggbb ou o nome de uma cor de camada +@msg unknown Live Paint option `{key}` opção de Pintura ao Vivo desconhecida `{key}` +@msg unknown Shape Builder option `{key}` opção do Construtor de Formas desconhecida `{key}` @msg `{key}` must be a #rrggbb colour `{key}` deve ser uma cor #rrggbb @msg `{key}` must be a number `{key}` deve ser um número @msg `{key}` must be a number, not {v} `{key}` deve ser um número, não {v} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af93b86974..e0221b4ab6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4465,6 +4465,9 @@ axis Both Оба @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 color `{key}` должно быть цветом #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` должно быть цветом #rrggbb или названием цвета слоя +@msg unknown Live Paint option `{key}` неизвестный параметр быстрой заливки `{key}` +@msg unknown Shape Builder option `{key}` неизвестный параметр создания фигур `{key}` @msg `{key}` must be a #rrggbb colour `{key}` должно быть цветом #rrggbb @msg `{key}` must be a number `{key}` должно быть числом @msg `{key}` must be a number, not {v} `{key}` должно быть числом, а не {v} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f26438a201..23f65fc29e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4338,6 +4338,9 @@ axis Both Båda @msg `{keep}` is not a side of this axis `{keep}` är inte en sida av den här axeln @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` måste vara `auto` eller en språkkod som `en` eller `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` måste vara en färg i formatet #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` måste vara en färg i formatet #rrggbb eller namnet på en lagerfärg +@msg unknown Live Paint option `{key}` okänt alternativ för direktmålning `{key}` +@msg unknown Shape Builder option `{key}` okänt alternativ för formbyggaren `{key}` @msg `{key}` must be a #rrggbb colour `{key}` måste vara en färg i formatet #rrggbb @msg `{key}` must be a number `{key}` måste vara ett tal @msg `{key}` must be a number, not {v} `{key}` måste vara ett tal, inte {v} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a91aab6f0e..9e1990b326 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5945,6 +5945,9 @@ theme Appearance Görünüm @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Kaydet {_1} yazar, {_2} değil: yazmak için file.saveAsTemplate kullanın @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` bir kütüphane paneli değil (bkz. window.swatchLibrary) @msg `{key}` must be a #rrggbb color `{key}` bir #rrggbb rengi olmalıdır +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` bir #rrggbb rengi ya da katman rengi adı olmalıdır +@msg unknown Live Paint option `{key}` bilinmeyen Canlı Boyama seçeneği `{key}` +@msg unknown Shape Builder option `{key}` bilinmeyen Şekil Oluşturucu seçeneği `{key}` @msg a key object sits on that point bu noktanın üzerinde bir anahtar nesne bulunuyor @msg a linked image can't become a gradient mesh: embed it first bağlantılı bir görüntü degrade ağına dönüştürülemez: önce görüntüyü gömün @msg a mask has more than one art block bir maskede birden fazla çizim bloğu var diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17ddeb2086..8182b2f18f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4464,6 +4464,9 @@ axis Both Обидві @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 color `{key}` має бути кольором #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` має бути кольором #rrggbb або назвою кольору шару +@msg unknown Live Paint option `{key}` невідомий параметр швидкої заливки `{key}` +@msg unknown Shape Builder option `{key}` невідомий параметр створення фігур `{key}` @msg `{key}` must be a #rrggbb colour `{key}` має бути кольором #rrggbb @msg `{key}` must be a number `{key}` має бути числом @msg `{key}` must be a number, not {v} `{key}` має бути числом, а не {v} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7d3d89a82d..b48dacac4a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4520,6 +4520,9 @@ theme Appearance Giao diện @msg `{keep}` is not a side of this axis `{keep}` không phải là một cạnh của trục này @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` phải là `auto` hoặc mã ngôn ngữ như `en` hoặc `zh-hant` @msg `{key}` must be a #rrggbb color `{key}` phải là màu #rrggbb +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` phải là màu #rrggbb hoặc tên màu lớp +@msg unknown Live Paint option `{key}` tùy chọn Tô trực tiếp không xác định `{key}` +@msg unknown Shape Builder option `{key}` tùy chọn Tạo hình dạng không xác định `{key}` @msg `{key}` must be a #rrggbb colour `{key}` phải là màu #rrggbb @msg `{key}` must be a number `{key}` phải là một số @msg `{key}` must be a number, not {v} `{key}` phải là một số, không phải {v} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 34ffeb5d6b..161ec8d3d4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6017,6 +6017,9 @@ lighting Light 光源 @msg a mask has more than one art block 蒙版包含多个图稿块 @msg mask art ends outside a mask dictionary 蒙版图稿在蒙版字典之外结束 @msg `{key}` must be a #rrggbb color `{key}` 必须是 #rrggbb 格式的颜色 +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` 必须是 #rrggbb 格式的颜色或图层颜色名称 +@msg unknown Live Paint option `{key}` 未知的实时上色选项 `{key}` +@msg unknown Shape Builder option `{key}` 未知的形状生成器选项 `{key}` @msg a linked image can't become a gradient mesh: embed it first 链接的图像无法转换为渐变网格:请先嵌入它 @msg the image has no usable size or position 图像没有可用的尺寸或位置 @msg the image's pixels can't be read 无法读取图像的像素 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 309e00346c..916afe9fcb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2810,6 +2810,9 @@ file-filter Templates 範本 @msg a mask has more than one art block 遮色片含有多個圖稿區塊 @msg mask art ends outside a mask dictionary 遮色片圖稿在遮色片字典之外結束 @msg `{key}` must be a #rrggbb color `{key}` 必須是 #rrggbb 格式的顏色 +@msg `{key}` must be a #rrggbb color or a layer color name `{key}` 必須是 #rrggbb 格式的顏色或圖層顏色名稱 +@msg unknown Live Paint option `{key}` 未知的即時上色選項 `{key}` +@msg unknown Shape Builder option `{key}` 未知的形狀建立選項 `{key}` @msg a linked image can't become a gradient mesh: embed it first 連結的影像無法轉換為漸層網格:請先內嵌 @msg the image has no usable size or position 影像沒有可用的尺寸或位置 @msg the image's pixels can't be read 無法讀取影像的像素 diff --git a/docs/mcp.md b/docs/mcp.md index 2b20cfc588..c79cb9cb9c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3419,7 +3419,10 @@ already walks Image Trace. Short agent entry points: tool opens them, dialog `livePaintSelectionOptions`) are `selectFills` and `selectStrokes` (both on), `color` (the highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the highlight's width, 1 to 20 screen pixels; 4). The dialog's OK gives an error for a `color` with an alpha part (`#rrggbbaa` or `#rgba`) and changes - nothing. + nothing. `tool.setOption` gives an error that names the option and sets none of the values, whether the tool is + chosen or not, for a `selectFills` or `selectStrokes` that is not `true` or `false`, a `color` that is not `#rrggbb`, + `#rgb` (with or without `#`), a layer color's name or a basic color name such as `white`, or that has an alpha part, + a `width` that is not a number, and an option the tool does not have. A `width` outside 1 to 20 is set to 1 or 20. With `selectStrokes` off, Alt-click selects the face under the pointer as a click does; with `selectFills` off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click selects no face or edge. A drag of 3 screen pixels or more draws a marquee, and the release runs `select.set {ids}` with @@ -3436,7 +3439,11 @@ already walks Image Trace. Short agent entry points: them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen pixels; 4; an edge's highlight is half as wide). The dialog's OK gives an error for a `color` with an alpha part - (`#rrggbbaa` or `#rgba`) and changes nothing. With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`. + (`#rrggbbaa` or `#rgba`) and changes nothing. `tool.setOption` gives an error that names the option and sets none of + the values, whether the tool is chosen or not, for a `paintFills`, `paintStrokes` or `highlight` that is not `true` + or `false`, a `color` that is not `#rrggbb`, `#rgb` (with or without `#`), a layer color's name or a basic color name + such as `white`, or that has an alpha part, a `width` that is not a number, and an option the tool does not have. A + `width` outside 1 to 20 is set to 1 or 20. With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`. With `paintStrokes` alone on, a click runs `livePaint.strokeEdge` on the edge within 4 screen pixels of the pointer, or nothing when none is in reach, and Shift-click runs `livePaint.fill`. With both on, a click runs `livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and @@ -9673,7 +9680,12 @@ pointer show the current fill and the swatches before and after it), `selection` default; when it is off, merge mode shades no regions), `gapDetection` (off) with `gapLength` (pt, 0 to 72; 3), `clickStrokeSplitsPath` (In Merge Mode, Clicking Stroke Splits the Path; off), `highlightStroke` (Highlight Stroke When Editable; on) and `highlightColor` (`#rrggbb` or a layer color's name; `#ff4f4f`, the layer color Red). The dialog's OK gives an error for a -`highlightColor` with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. With `clickStrokeSplitsPath` on, in merge mode, +`highlightColor` with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. `tool.setOption` gives an error that +names the option and sets none of the values, whether the tool is chosen or not, for a switch that is not `true` or +`false`, a `pickColorFrom` other than `artwork` or `swatches`, a `selection` other than `freeform` or `straightLine`, a +`gapLength` that is not a number, a `highlightColor` that is not `#rrggbb`, `#rgb` (with or without `#`), a layer +color's name or a basic color name such as `white`, or that has an alpha part, and an option the tool does not have. A +`gapLength` outside 0 to 72 is set to 0 or 72. With `clickStrokeSplitsPath` on, in merge mode, the pointer shows scissors within 4 screen pixels of a stroke, `highlightStroke` draws the run of stroke under it in `highlightColor`, and a click splits that run off its path: the run goes from the anchor or crossing point before the pointer to the one after it, the path keeps the rest of it, and a copy of the path with the run, in the path's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7782bdfcd..493c59a27f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1401,6 +1401,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). As VectorCraft's own rule, OK in Live Paint Selection Tool Options, Live Paint Bucket Options, Gap Options and Tile Edge Color gives an error for a color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. + As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. + As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. From 5c0f22b345dfde6bcf39154ad0e619a6fb71f13c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:31:54 -0800 Subject: [PATCH 07/27] Graphs: Appearance edits on a label are kept for every label of its kind A fill or stroke row that the Appearance commands gave a graph label, its object opacity, and the characters the Eyedropper gave it lasted only until the graph was drawn again: the graph stored nothing for a label's rows, and the Eyedropper's character attributes were not stored. - The graph keeps, for value axis numbers, category labels and legend labels, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke row, their order and place relative to the Characters row, and the label's object opacity and blend mode (labelStyles valuesRows, categoriesRows, legendRows). Every label of the kind gets those rows when the graph is drawn again, with a placed gradient fitted to each label. - Every appearance.* command stores them, and so do graphicStyle.apply and redefine and the paint, stroke, gradient and transparency edits aimed at a row, even when the label already showed them. transparency.set without a row and object.setProps store only the object opacity and blend mode. With several labels of a kind, the last one edited wins. - appearance.copyFrom onto all of a label's text stores the character attributes it applied and, from type, the paragraph attributes; a transparency pick stores the object opacity and blend mode, and append stores the rows. - paint.setFill and paint.setStroke store a character fill or stroke only when the characters take the paint (the whole label, or all of its text highlighted with the Type tool); aimed at a row, they store the row. - Document Color Mode and swatch edits reach the kept rows. Docs: docs/mcp.md, the commands' params text, docs/target-app-parity.md. --- crates/doc/src/graph.rs | 26 ++ crates/doc/src/lib.rs | 4 +- crates/doc/src/swatches.rs | 20 +- crates/engine/src/cmd/appearance.rs | 111 +++++-- crates/engine/src/cmd/colormgmt.rs | 5 + crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 6 +- crates/engine/src/cmd/graph/labels.rs | 50 ++- crates/engine/src/cmd/graph/series.rs | 146 +++++++++ crates/engine/src/cmd/graph/tests.rs | 401 +++++++++++++++++++++++ crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/props.rs | 8 +- crates/engine/src/cmd/paint.rs | 38 ++- crates/engine/src/cmd/stroke.rs | 2 +- crates/engine/src/cmd/style.rs | 7 +- crates/engine/src/tests/swatches.rs | 10 +- docs/mcp.md | 16 + docs/target-app-parity.md | 2 +- 19 files changed, 805 insertions(+), 55 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index f3b655d76a..43074a4e80 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -166,6 +166,16 @@ pub struct LabelStyles { /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. #[serde(skip_serializing_if = "Option::is_none")] pub legend_anti_alias: Option, + /// The object fill and stroke rows the value axis numbers keep once set with the Appearance controls ([`LabelRows`]). + /// `None`: no rows, opaque and Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_rows: Option, + /// The object fill and stroke rows the category labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_rows: Option, + /// The object fill and stroke rows the legend labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_rows: Option, } impl LabelStyles { @@ -355,6 +365,22 @@ pub struct SeriesPaint { pub blend: Option, } +/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode +/// of a label's topmost visible fill and stroke row (an absent paint: no row of that kind), the label's object opacity and +/// blend mode, and where the two rows paint relative to each other and to the characters. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LabelRows { + #[serde(flatten)] + pub paint: SeriesPaint, + /// How many of the kept rows paint below the characters, counted from the bottom. Absent: none. + #[serde(skip_serializing_if = "Option::is_none")] + pub below: Option, + /// The stroke row paints below the fill row. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_under: bool, +} + /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each /// holding one value per series; `series` are the legend labels (the first row of the window). /// Scatter graphs read each series as (y, x) column pairs, like Illustrator. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..bc5a4a6272 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -92,8 +92,8 @@ pub use compound_shape::ShapeMode; pub use corners::LiveCorners; pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES}; pub use graph::{ - ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, - PieSort, SeriesPaint, TickLength, ValueAxisSide, + ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelRows, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, + PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index ba79f7c280..844aa8c810 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -388,7 +388,9 @@ pub fn graph_paints(n: &Node) -> impl Iterator { let g = n.extras().graph.as_deref(); let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten(); let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten(); - series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])) + let l = g.map(|g| &g.label_styles); + let rows = l.into_iter().flat_map(|l| [&l.values_rows, &l.categories_rows, &l.legend_rows]).flatten(); + series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])).chain(rows.flat_map(|r| [r.paint.fill.as_ref(), r.paint.stroke.as_ref()]).flatten()) } /// Mutable [`graph_paints`] of graph `g`, in the same order. @@ -396,7 +398,10 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; - assert_eq!(d.map_solid_paints(&mut to_blue), 4); + assert_eq!(d.map_solid_paints(&mut to_blue), 5); let g = d.node(a).unwrap().extras().graph.clone().unwrap(); assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand"))); assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand"))); let legend = g.label_styles.legend.unwrap(); assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.fill, Some(linked(blue, "Brand"))); let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None); - assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three"); + assert_eq!(d.map_paints(&mut cleared), 5, "the fill and the graph's four"); assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5fb747b7e2..e30ba811d4 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -545,15 +545,21 @@ pub(crate) fn edit_items_then( let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); + // The objects whose rows `f` edited: on type, no row means its characters. + let mut rows = Vec::new(); for (id, item) in ids.iter().zip(&per) { let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; edited.push(*id); + if index.is_some() { + rows.push(*id); + } } let more = after(&before, d, &edited)?; - Ok(count_changed(d, &before, &edited) + usize::from(more)) + let labels = super::graph::capture_label_rows(d, &rows); + Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; Ok(()) } @@ -690,6 +696,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -712,7 +719,8 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); let object = super::graph::capture_series_object_opacity(d, &ids); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; ok() @@ -911,8 +919,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, both_kinds(&targets))); let painted = super::graph::capture_series_top_paints(d, &rows); let object = super::graph::capture_series_object_opacity(d, &changed); + // The targets last: on two labels of one kind, the target's rows are the ones the kind keeps. + let labels = super::graph::capture_label_rows(d, &changed); let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); - Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -968,7 +978,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; s.active_appearance_item = None; ok() @@ -1043,7 +1054,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, written)); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -1089,7 +1101,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The rows removed from the active row's object, which need not come first in `ids`. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); @@ -1173,6 +1186,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -1212,6 +1226,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); + super::graph::capture_label_rows(d, &shown); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -1257,7 +1272,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1302,7 +1318,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { slot.get_or_insert(n.appearance.contents_at()); } slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?; - Ok(count_changed(d, &before, ids)) + // A graph label's kind keeps where its rows paint relative to the characters. + let labels = super::graph::capture_label_rows(d, ids); + Ok(count_changed(d, &before, ids) + usize::from(labels)) })?; Ok(json!({ "contents": slot })) } @@ -1489,9 +1508,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(finish(s, &[])); } + // What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its + // characters, with a picked gradient fitted to each label, and the picked paragraph attributes. + let source_rows = picked.placed(None); + let kept_text = |id: NodeId, st: &mut CharStyle| { + Picked::run(st, &source_rows, &attrs, characters.get(&id)); + if attrs.appearance.fill.color { + st.fill = super::gradient::unplaced(&st.fill); + } + if attrs.appearance.stroke.color { + st.stroke = super::gradient::unplaced(&st.stroke); + } + }; + let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para); + // Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes + // what a kind keeps for its characters. + let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke); + let to_characters = (attrs.character && picked.text.is_some()) + || f.color + || f.overprint + || k.color + || k.overprint + || k.weight + || k.cap + || k.join + || k.miter + || k.dash; + let kept_text: Option = to_characters.then_some(&kept_text); if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); + // The labels whose whole text is highlighted. + let whole: Vec = ranges + .iter() + .filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) { + (None, _) => true, + (Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs), + _ => false, + }) + .map(|(id, _)| *id) + .collect(); super::overprint::edit_counted(s, "Eyedropper", |d| { for &(id, range) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -1504,7 +1560,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { super::typecmd::refresh_bounds(t); } check_text_bounds(d)?; - Ok(count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?; + Ok(count_changed(d, &before, &ids) + usize::from(labels)) })?; return Ok(finish(s, &ids)); } @@ -1559,7 +1616,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { // A pick of the object's transparency writes it on every target. let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; let object = super::graph::capture_series_object_opacity(d, &objects); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) + // Graph labels keep their new rows, characters and paragraphs per kind of label. + // On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other + // attributes go to the characters. + let rows = if append { + super::graph::capture_label_rows(d, &targets) + } else { + attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets) + }; + // `append` adds rows and leaves the characters as they were. + let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?; + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text)) })?; Ok(finish(s, &targets)) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index c94de5d3b2..66295f50f2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color map_paint_colors(&mut st.fill, f); map_paint_colors(&mut st.stroke, f); } + for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { + for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() { + map_paint_colors(paint, f); + } + } } /// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link. diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index f4ec784af3..bdba729211 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9b41eea001..54ca07da7e 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4452991998..e283afd752 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,9 +45,9 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, - shown_paint, + LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, + capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, + paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index b115714256..290e099500 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -73,6 +73,44 @@ pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role kept.unwrap_or_default() } +/// The object rows labels of `role` keep, if any. +fn kept_rows(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::LabelRows> { + match role { + Role::Value => styles.values_rows.as_ref(), + Role::Category => styles.categories_rows.as_ref(), + Role::Legend => styles.legend_rows.as_ref(), + } +} + +/// Give label `n` the object rows `kept` keeps: a fill row and a stroke row for the kept paints, in their kept order and +/// place relative to the characters, and the object opacity and blend mode. An opacity read from a file is held to 0..1 +/// and a weight to 0..1000 pt; one that is not a number is left out (a stroke then takes 1 pt). +fn apply_label_rows(n: &mut Node, kept: &vectorcraft_doc::LabelRows) { + use vectorcraft_doc::{AppearanceItem, FillLayer, StrokeLayer}; + let rows = &kept.paint; + let unit = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); + let fill = rows.fill.as_ref().map(|paint| { + let mut f = FillLayer::new(paint.clone()); + f.opacity = unit(rows.fill_opacity).unwrap_or(1.0); + f.blend = rows.fill_blend.unwrap_or_default(); + AppearanceItem::Fill(f) + }); + let stroke = rows.stroke.as_ref().map(|paint| { + let width = rows.stroke_width.filter(|w| w.is_finite()).map_or(1.0, |w| w.clamp(0.0, 1000.0)); + let mut st = StrokeLayer::new(paint.clone(), width); + st.opacity = unit(rows.stroke_opacity).unwrap_or(1.0); + st.blend = rows.stroke_blend.unwrap_or_default(); + AppearanceItem::Stroke(st) + }); + let items: Vec = if kept.stroke_under { [stroke, fill] } else { [fill, stroke] }.into_iter().flatten().collect(); + let mut ap = Appearance::clone(&n.appearance); + ap.items = items; + ap.set_contents_at(usize::from(kept.below.unwrap_or(0))); + n.appearance = ap.into(); + n.opacity = unit(rows.opacity).unwrap_or(1.0); + n.blend = rows.blend.unwrap_or_default(); +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -90,7 +128,15 @@ impl Gen<'_> { } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { let t = self.text_object(role, at, s, justify); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) + } + /// Label text `t` of `role` as a node, with the object rows its kind keeps. + fn label_node(&mut self, role: Role, t: TextObject) -> Arc { + let mut n = Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(rows) = kept_rows(self.styles, role) { + apply_label_rows(&mut n, rows); + } + Arc::new(n) } /// Label `s` of `role` set at `at` and laid out, not yet in the document. fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { @@ -121,7 +167,7 @@ impl Gen<'_> { t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; } } - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 3285185b8d..bfde9e881a 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -250,6 +250,9 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { /// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]). type SeriesPaintsBefore = HashMap>; +/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]). +pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle); + /// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the /// captures added past the old end and left empty are removed again, so they do not count as a change. fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool { @@ -522,6 +525,149 @@ pub(crate) fn capture_label_paragraphs( Ok(changed) } +/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of +/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed +/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal. +pub(crate) fn label_rows(n: &Node) -> Option { + use vectorcraft_doc::AppearanceItem; + let items = &n.appearance.items; + let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible()); + let opacity = |o: f32| (o != 1.0).then_some(o); + let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b); + let unplaced = crate::cmd::gradient::unplaced; + let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() }; + let (fill, stroke) = (top(true), top(false)); + if let Some(f) = fill.and_then(|i| items.get(i)) { + (rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend())); + } + if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) { + (rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) = + (Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend)); + } + let contents = n.appearance.contents_at(); + let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1); + let rows = vectorcraft_doc::LabelRows { + paint: rows, + below: (below > 0).then_some(below), + stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f), + }; + (rows != vectorcraft_doc::LabelRows::default()).then_some(rows) +} + +/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind +/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are +/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed. +pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, false) +} + +/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`] +/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed. +pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, true) +} + +/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying +/// it. +pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool { + label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| { + let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false }; + let slot = match role { + Role::Value => &spec.label_styles.values_rows, + Role::Category => &spec.label_styles.categories_rows, + Role::Legend => &spec.label_styles.legend_rows, + }; + *slot != kept_label_rows(slot, rows, true) + }) +} + +/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`]. +fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool { + let mut changed = false; + for (graph, role, rows) in label_rows_of(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_rows, + Role::Category => &mut spec.label_styles.categories_rows, + Role::Legend => &mut spec.label_styles.legend_rows, + }; + let kept = kept_label_rows(slot, rows, object_only); + changed |= *slot != kept; + *slot = kept; + } + changed +} + +/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the +/// label's object opacity and blend mode. +fn kept_label_rows( + slot: &Option, + rows: Option, + object_only: bool, +) -> Option { + if !object_only { + return rows; + } + let mut kept = slot.clone().unwrap_or_default(); + let shown = rows.unwrap_or_default(); + (kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend); + (kept != vectorcraft_doc::LabelRows::default()).then_some(kept) +} + +/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`. +fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option)> { + // Graph labels are type: without type among `ids`, the graph's labels are not looked up. + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return vec![]; + } + let labels = graph_labels(doc); + let mut picked: Vec<(NodeId, Role, Option)> = vec![]; + for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) { + if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) { + picked.push((g, role, label_rows(n))); + } + } + picked +} + +/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the +/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a +/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]). +/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes +/// changed. +pub(crate) fn capture_label_text( + doc: &mut Document, + ids: &[NodeId], + change: Option, + para: Option<&vectorcraft_doc::ParaStyle>, +) -> Result { + if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return Ok(false); + } + // The last label of each kind sets what the kind keeps. + let labels = graph_labels(doc); + let mut last: Vec<((NodeId, Role), NodeId)> = vec![]; + for id in ids.iter().rev() { + if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) { + last.push((*kind, *id)); + } + } + let mut changed = false; + for id in last.iter().rev().map(|(_, id)| id) { + if let Some(change) = change { + changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st)); + } + if let Some(para) = para { + changed |= capture_label_paragraphs(doc, &[*id], &|p| { + *p = para.clone(); + Ok(()) + })?; + } + } + Ok(changed) +} + /// The graphs and kinds of label among `ids`, each once. fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { if ids.is_empty() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 067d2faeb5..bcd6f406ed 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5079,6 +5079,407 @@ fn formatting_one_label_keeps_that_kind_of_label() { assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); } +/// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and +/// for a stroke its weight) and its object opacity. +type RowLooks = Vec<(bool, String, f32, f64)>; +fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> { + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + let rows = c + .appearance + .items + .iter() + .map(|it| { + let w = match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => st.width, + vectorcraft_doc::AppearanceItem::Fill(_) => 0.0, + }; + (it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w) + }) + .collect(); + out.push((t.plain_text(), rows, c.opacity)); + } + } + } + out +} + +fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) { + looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}")) +} + +/// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the +/// graph is drawn again, through save and open, and undo with the change. +#[test] +fn appearance_rows_on_one_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // A category label: a red fill row at 50%. + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap(); + // A value axis number: a 2 pt blue stroke row. + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}"); + assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}"); + assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}"); + // One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).label_styles.values_rows.unwrap(); + assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(kept.paint.stroke_width, Some(1.0)); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0)); + assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty()); +} + +/// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows. +#[test] +fn removing_a_labels_rows_changes_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing"); + s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}"); +} + +/// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill. +#[test] +fn painting_a_labels_row_keeps_the_row_and_not_its_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into())); + assert_eq!(g.label_styles.legend, None, "the characters keep the default fill"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}"); +} + +/// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the +/// label's kind. +#[test] +fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap(); + let v = text_id(&s, id, "Axes", "2"); + s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + let (rows, opacity) = rows_of(&looks, "6"); + assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}"); +} + +/// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the +/// label for every label of its kind. +#[test] +fn the_eyedropper_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}"); + assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}"); + // Appended rows (Shift+Alt-click) go with the label's rows. + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap(); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}"); +} + +/// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the +/// Characters row past the kept rows puts every row below the characters. +#[test] +fn kept_label_rows_from_a_file_stay_in_range() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap(); + let saved = json!({"legendRows": { + "fill": paint("#ff0000"), "fillOpacity": 7.0, + "stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0, + "opacity": -3.0, "below": 9, + }}); + let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap(); + s.edit("rows", |d, _| { + d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; + Ok(()) + }) + .unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}"); + assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2); +} + +/// Label `text` of group `grp` of graph `id`. +fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone() +} + +/// An edit that leaves a label showing what it showed still sets what its kind keeps. +#[test] +fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 shows the red the graph drew it with. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// Paint given to a label's characters leaves the rows its kind keeps as they are. +#[test] +fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 still shows red; its characters take green. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + // The Eyedropper from type, transparency included, onto its characters. + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps. +#[test] +fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2")); + for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] { + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap(); + } + let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex(); + s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(kept(&s), "#0000ff"); + s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap(); + assert_eq!(kept(&s), "#ff0000"); +} + +/// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind. +#[test] +fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row. + let u = text_id(&s, id, "Axes", "2"); + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal); + // Another label of the kind sets them again. + let w = text_id(&s, id, "Axes", "2"); + s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap(); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen); +} + +/// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's +/// blend mode are kept for the label's kind. +#[test] +fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap(); + s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}"); + assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}"); + let q1 = label_node(&s, id, "Axes", "Q1"); + assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply)); + let NodeKind::Text(t) = &q1.kind else { panic!("not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill"); +} + +/// A row below the Characters row stays below the characters when the graph is drawn again. +#[test] +fn a_kept_row_keeps_its_place_below_the_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + // The stroke below the characters, the fill above them. + s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap(); + let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::>()); + assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true])); + redrawn_label_rows(&mut s, id); + assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true])); +} + +/// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out. +#[test] +fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + // A fill of the label's own, as the generator gives labels on dark pie wedges. + s.edit("own fill", |d, _| { + if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind { + t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + } + Ok(()) + }) + .unwrap(); + // A pick of transparency alone leaves the kind's character style unset. + let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}}); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap(); + assert_eq!(spec(&s, id).label_styles.values, None); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}"); +} + +/// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label. +#[test] +fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1})); + s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap(); + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}"); +} + +/// Paint given to the whole graph without a row is kept for the characters of every kind of label. +#[test] +fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff00ff"})).unwrap(); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}"); + } +} + +/// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of +/// the kind when the graph is drawn again. +#[test] +fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let gradient = json!({"start": [30, 60], "end": [90, 60]}); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap(); + let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some()); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill))); + assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f))); +} + +/// A graphic style applied to a label is kept with the label's rows for its kind. +#[test] +fn a_graphic_style_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}"); + // A label still linked to the style follows a redefinition, and its kind keeps the new look. + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 21d6530e53..6ffdb04a5c 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), 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)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), 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)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 1fb2faf769..bd00b6b323 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,10 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark - // already has still replaces the one another mark left on the graph. + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label + // for its kind of label, so a value a mark or label already has still replaces the one another left on the graph. let transparency = opacity.is_some() || blend.is_some(); - let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + let stores = + || transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_object_opacity_differs(d, &ids)); if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { @@ -124,6 +125,7 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { } if transparency { crate::cmd::graph::capture_series_object_opacity(d, &ids); + crate::cmd::graph::capture_label_object_opacity(d, &ids); } // Hidden or locked objects can't stay selected. if hides { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c0f23100af..c7159d563a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md)", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -475,6 +475,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The graph labels whose whole text takes the paint: a graph keeps it for their kind of label. + let whole: Vec = ranges + .iter() + .filter(|(id, a, b)| { + *a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs)) + }) + .map(|(id, _, _)| *id) + .collect(); super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| { for &(id, a, b) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -491,7 +499,17 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }); super::typecmd::refresh_bounds(t); } - Ok(super::appearance::count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_styles(d, &whole, &|st| { + if fill { + st.fill = unplaced(&paint); + } else { + if !paint.is_none() { + run_stroke_weight(st); + } + st.stroke = unplaced(&paint); + } + }); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels)) })?; if focus { s.fill_active = fill; @@ -534,6 +552,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series // (a series mark is a path, so it always has a row here). let written = std::cell::RefCell::new(Vec::new()); + // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep + // for their characters; a row edit is kept with the label's rows. + let characters = std::cell::RefCell::new(Vec::new()); edit_items_then( s, &ids, @@ -542,6 +563,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) label, fill, |n, index| { + if index.is_none() && matches!(n.kind, NodeKind::Text(_)) { + characters.borrow_mut().push(n.id); + } if index.is_none() && colorized.contains(&n.id) { n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone())); return Ok(()); @@ -601,14 +625,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, |before, d, changed| { let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow()); - let labels = super::graph::capture_label_styles(d, changed, &|st| { + let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| { if fill { - st.fill = paint.clone(); + st.fill = unplaced(&paint); } else { if !paint.is_none() { run_stroke_weight(st); } - st.stroke = paint.clone(); + st.stroke = unplaced(&paint); } }); Ok(series || labels) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 14c86c06ed..c5e5ba2a5c 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 65fe18b282..1c41006b3f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Redefine Graphic Style", ["Window", "Appearance"], None, - "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}", + "{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", has_doc, style_redefine ), @@ -374,6 +374,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { super::appearance::clear_stood_for(d, &groups); super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } + // A label keeps its new rows for its kind of label. + super::graph::capture_label_rows(d, &ids); Ok(()) })?; ok() @@ -506,6 +508,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { apply_style(d, *nid, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); + super::graph::capture_label_rows(d, &follow_marks); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/crates/engine/src/tests/swatches.rs b/crates/engine/src/tests/swatches.rs index c0145d20d4..a80769dd98 100644 --- a/crates/engine/src/tests/swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -282,20 +282,24 @@ fn document_color_mode_converts_the_paints_a_graph_keeps() { for target in [series, label] { run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [target.0]})); } + // A fill row on the label, which the graph keeps with the label's rows. + run(&mut s, "appearance.addFill", json!({"ids": [label.0]})); let kept = |s: &Session| -> Vec { let g = doc(s).node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let rows = [&g.label_styles.values_rows, &g.label_styles.categories_rows, &g.label_styles.legend_rows]; + let rows: Vec = rows.into_iter().flatten().filter_map(|r| r.paint.fill.as_ref()?.color()).collect(); let labels = [g.label_styles.values, g.label_styles.categories, g.label_styles.legend]; let labels = labels.into_iter().flatten().map(|st| st.fill.color().unwrap()); - g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).collect() + g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).chain(rows).collect() }; let before = kept(&s); - assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 2]); + assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 3]); let undo = s.doc().unwrap().history.undo.len(); run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); let cmyk = first_fill(doc(&s).node(red).unwrap()); assert!(matches!(cmyk, Color::Cmyk { .. }), "{cmyk:?}"); - assert_eq!(kept(&s), [cmyk; 2]); + assert_eq!(kept(&s), [cmyk; 3]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); // Drawn again from new data, the series keeps the converted color. run(&mut s, "graph.setData", json!({"id": id.0, "rows": [[2, 3], [4, 5]]})); diff --git a/docs/mcp.md b/docs/mcp.md index 2bfc83e64f..e4b129d75f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -812,6 +812,22 @@ kind takes them when the graph is drawn again. The graph saves the paragraph att `categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend leaves room for a legend label's stored indents, spacing and leading. +The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and +transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its +kind of label: the paint, weight, opacity and blend mode of its topmost visible fill and stroke row, their order and +their place relative to the Characters row, and the label's object opacity and blend mode. They store them even when the +label already showed them. `transparency.set` without an `item`, `object.setProps` with an opacity or blend mode, and +an `appearance.copyFrom` (Eyedropper) pick of transparency store only the label's object opacity and blend mode; +`appearance.copyFrom` with `append` stores the rows. When the graph is drawn again, every label of that kind gets what +is stored, with a placed gradient fitted to each label, and the graph saves it as `valuesRows`, `categoriesRows` and +`legendRows` in its label styles. Rows below the topmost one of each kind, a row's effects, other stroke options (caps, +joins, dashes) and effects applied with `effect.*` are not stored. When labels of one kind are edited together, the +last one in the order of the targets (`ids` as given, otherwise paint order) sets what the kind keeps. +`appearance.copyFrom` onto the whole text of a label also stores the character attributes it applies and, from type, +the paragraph attributes. `paint.setFill` and `paint.setStroke` store a character fill or stroke when the paint goes to +the label's characters (the whole label, or all of its text highlighted with the Type tool), and the row when it goes to +a row. A stored character gradient also fits each label. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2d64b6c0f..df6e7481fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1084,7 +1084,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. From f42ec13bd241389da2570c84912a492fd282a891 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:29:58 -0800 Subject: [PATCH 08/27] PDF: the long-page warning names the in-points option In a PDF without a standard, the warning for a page longer than 14,400 pt written with a UserUnit above 1 now ends with "turn on Write pages longer than 200 in at full size in points". The warning already said that apps ignoring UserUnit show such a page smaller; it now names the Save PDF option that writes the page at its size in points instead. PDF/A files, which keep the UserUnit with the option on, and .ai saves, whose options do not have it, keep the warning as it was. The longer message has a row in every complete-message catalog. The params text of document.exportPdf, docs/mcp.md and the evidence line in docs/target-app-parity.md describe it. --- crates/engine/src/cmd/fileio/pdf.rs | 16 ++++- crates/engine/src/cmd/fileio/tests_pdf.rs | 64 ++++++++++++++++++++ crates/engine/src/tests/pdfpresets.rs | 8 ++- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 7 ++- crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 29 files changed, 118 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ec7d43472d..c834e653e4 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,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), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), 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, without the layers the file leaves out; 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; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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, pdfX3 and pdfX4 always, whatever profiles says), 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: 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; true: every glyph as an outline), 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}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {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. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{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), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), 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, without the layers the file leaves out; 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; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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, pdfX3 and pdfX4 always, whatever profiles says), 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: 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; true: every glyph as an outline), 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}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {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. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -305,6 +305,15 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result = placed.into_iter().map(Note::loss).collect(); let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; notes.extend(drawn); + if notes.iter().any(|n| n.text.ends_with(SHOWN_SMALLER)) && options(cmd, &full, p)?.settings.standard == Standard::None { + for n in notes.iter_mut().filter(|n| n.text.ends_with(SHOWN_SMALLER)) { + n.text.push_str(LONG_PAGES_IN_POINTS); + } + } notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); Ok((bytes, notes)) } diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 91fe9701e7..bd52fe71c7 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -103,6 +103,70 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() { } } +/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a +/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5 +/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the +/// art's bounds and the Summary's export, with one such warning per page. PDF/A files and `.ai` +/// saves keep the warning without the option's name. Pages written in points (the option on, PDF +/// 1.4, PDF/X, a page too narrow for a `UserUnit` above 1) and a page too long and narrow for +/// 14,400 units have other warnings, which don't name it. +#[test] +fn a_page_with_a_user_unit_names_the_option_that_writes_it_in_points() { + use super::pdf::LONG_PAGES_IN_POINTS; + const SMALLER: &str = "so apps that ignore UserUnit show it that many times smaller"; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20_000, "height": 1_000})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 19_000, "height": 500})).unwrap(); + let warnings = |v: &Value| -> Vec { + v["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("UserUnit")).map(str::to_string).collect() + }; + let named = |v: &Value| warnings(v).iter().any(|w| w.contains(LONG_PAGES_IN_POINTS)); + for p in [ + json!({"longPagesInPoints": false}), + json!({"longPagesInPoints": false, "compatibility": "1.5"}), + json!({"longPagesInPoints": false, "compatibility": "2.0"}), + json!({"longPagesInPoints": false, "useArtboards": false}), + ] { + let mut q = p.clone(); + q["includeDocument"] = json!(true); + for v in [export(&mut s, p.clone()), s.execute("document.export", &as_export(&p)).unwrap(), s.execute("document.pdfSettings", &q).unwrap()] { + let page: Vec = warnings(&v).into_iter().filter(|w| w.starts_with("the page of artboard")).collect(); + assert_eq!(page.len(), 1, "{p}: {v}"); + assert!(page[0].ends_with(&format!("{SMALLER}{LONG_PAGES_IN_POINTS}")), "{p}: {page:?}"); + assert_eq!(warnings(&v).iter().filter(|w| w.contains("apps that ignore UserUnit")).count(), 1, "{p}: {v}"); + } + } + for p in [ + json!({"longPagesInPoints": true}), + json!({"longPagesInPoints": false, "compatibility": "1.4"}), + json!({"longPagesInPoints": false, "preset": "PDF/X-4:2010"}), + ] { + assert!(!named(&export(&mut s, p.clone())), "{p}"); + } + for p in + [json!({"longPagesInPoints": false, "standard": "pdfA2b"}), json!({"longPagesInPoints": false, "standard": "pdfA2b", "compatibility": "1.4"})] + { + let v = export(&mut s, p.clone()); + assert!(warnings(&v).iter().any(|w| w.ends_with(SMALLER)) && !named(&v), "{p}: {v}"); + } + let ai = s.execute("document.export", &json!({"format": "ai", "longPagesInPoints": false})).unwrap(); + assert!(warnings(&ai).iter().any(|w| w.ends_with(SMALLER)) && !named(&ai), "{ai}"); + // The art's bounds are within 14,400 pt. + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 500, "height": 500})).unwrap(); + assert!(!named(&export(&mut s, json!({"longPagesInPoints": false, "useArtboards": false})))); + // A page 3 pt high keeps a UserUnit of 1, so it is written in points either way; a page + // 85,040 × 17 pt gets a UserUnit above 1 that leaves its long side past 14,400 units. + for (w, h) in [(20_000, 3), (85_040, 17)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": w, "height": h})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": w - 1_000, "height": h})).unwrap(); + let v = export(&mut s, json!({"longPagesInPoints": false})); + assert!(!named(&v), "{w} × {h}: {v}"); + } +} + /// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`, /// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its /// editing data would replace the document's artboards with the one around the art. A PDF of the diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index ecdf140c0e..7d765106aa 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -164,7 +164,13 @@ fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); let bytes = pdf(&v); assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969")); - assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}"); + assert!( + warned( + &v, + "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points" + ), + "{v}" + ); reopened(&bytes); for bad in [json!("yes"), json!(1)] { assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index fda9e78cc5..be3461abe9 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -244,8 +244,9 @@ fn summary_warnings_are_translated() { } /// The warnings and the error for a page longer than 14,400 pt are translated: a PDF 1.5 file -/// written as PDF 1.6, a page with a `UserUnit`, a PDF/X-4 page in points, a refused PDF/X-1a -/// file and a page too narrow for the `UserUnit` its long side needs. +/// written as PDF 1.6, a page with a `UserUnit` with the option that writes it in points, a +/// PDF/X-4 page in points, a refused PDF/X-1a file and a page too narrow for the `UserUnit` its +/// long side needs. #[test] fn long_page_notes_are_translated() { let mut s = Session::new(); @@ -262,7 +263,7 @@ fn long_page_notes_are_translated() { let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); assert_eq!(notes.len(), 5, "{notes:?}"); - let english = ["has no UserUnit, which", "apps that ignore UserUnit", "apps that keep to the limit", "real numbers up to", "too long and narrow"]; + let english = ["has no UserUnit, which", "apps that ignore UserUnit", "apps that keep to the limit", "real numbers up to", "too long and narrow", "turn on"]; for code in ["de", "fr", "ja", "zh-hans"] { let lang = crate::i18n::Lang::from_code(code).unwrap(); for note in ¬es { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b0159ebc14..884f38f4d7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5633,6 +5633,7 @@ digits Malayalam Malaiàlam @msg invalid parameters for `{cmd}`: {msg} paràmetres no vàlids per a `{cmd}`: {msg} @msg invalid PDF setting: {0} configuració del PDF no vàlida: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): s’escriu amb un UserUnit de {unit}, de manera que les aplicacions que ignoren UserUnit la mostren tantes vegades més petita +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): s’escriu amb un UserUnit de {unit}, de manera que les aplicacions que ignoren UserUnit la mostren tantes vegades més petita: activeu «Escriu les pàgines de més de 200 in a mida real, en punts» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): les pàgines {label} s’escriuen sense UserUnit, en punts, i les aplicacions que respecten el límit retallen la pàgina a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 el PDF 1.5 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per mantenir la mida: el fitxer s’escriu com a PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 el PDF 1.4 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per quedar dins del límit de pàgina de 14.400 unitats del PDF/A-2: el fitxer s’escriu com a PDF 1.6 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 27b2d32771..11932a4859 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4896,6 +4896,7 @@ imageTrace Outline Obrys @msg internal error: {msg} (please report this bug) vnitřní chyba: {msg} (nahlaste prosím tuto chybu) @msg invalid PDF setting: {0} neplatné nastavení PDF: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): zapisuje se s UserUnit {unit}, takže aplikace, které UserUnit ignorují, ji zobrazí tolikrát menší +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): zapisuje se s UserUnit {unit}, takže aplikace, které UserUnit ignorují, ji zobrazí tolikrát menší: zapněte „Zapsat stránky delší než 200 in v plné velikosti v bodech“ @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): stránky {label} se zapisují bez UserUnit, v bodech, a aplikace, které limit dodržují, stránku oříznou na 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby si zachovala velikost: soubor se zapíše jako PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby zůstala v limitu stránky PDF/A-2, 14 400 jednotek: soubor se zapíše jako PDF 1.6 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b6cd3e8a6..4b808678d9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4510,6 +4510,7 @@ theme Appearance Erscheinungsbild @msg internal error: {msg} (please report this bug) interner Fehler: {msg} (bitte melden Sie diesen Fehler) @msg invalid PDF setting: {0} ungültige PDF-Einstellung: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): sie wird mit einer UserUnit von {unit} geschrieben, daher zeigen Apps, die UserUnit ignorieren, sie entsprechend oft kleiner an +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): sie wird mit einer UserUnit von {unit} geschrieben, daher zeigen Apps, die UserUnit ignorieren, sie entsprechend oft kleiner an: „Seiten über 200 in in voller Größe in Punkt schreiben“ aktivieren @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): {label}-Seiten werden ohne UserUnit in Punkt geschrieben, und Apps, die die Grenze einhalten, schneiden die Seite auf 200 in ab @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um ihre Größe zu behalten: die Datei wird als PDF 1.6 geschrieben @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um innerhalb der PDF/A-2-Seitengrenze von 14.400 Einheiten zu bleiben: die Datei wird als PDF 1.6 geschrieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8620169f21..ec02d7747c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4543,6 +4543,7 @@ digits Malayalam Μαλαγιάλαμ @msg internal error: {msg} (please report this bug) εσωτερικό σφάλμα: {msg} (αναφέρετε το σφάλμα) @msg invalid PDF setting: {0} μη έγκυρη ρύθμιση PDF: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): γράφεται με UserUnit {unit}, οπότε οι εφαρμογές που αγνοούν το UserUnit την εμφανίζουν τόσες φορές μικρότερη +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): γράφεται με UserUnit {unit}, οπότε οι εφαρμογές που αγνοούν το UserUnit την εμφανίζουν τόσες φορές μικρότερη: ενεργοποιήστε την «Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): οι σελίδες {label} γράφονται χωρίς UserUnit, σε στιγμές, και οι εφαρμογές που τηρούν το όριο κόβουν τη σελίδα στις 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 το PDF 1.5 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να κρατήσει το μέγεθός της: το αρχείο γράφεται ως PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 το PDF 1.4 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να μείνει εντός του ορίου σελίδας του PDF/A-2, 14.400 μονάδων: το αρχείο γράφεται ως PDF 1.6 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a82db20a99..a910382eaa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4587,6 +4587,7 @@ axis Both Ambos @msg internal error: {msg} (please report this bug) error interno: {msg} (por favor, informa de este error) @msg invalid PDF setting: {0} ajuste de PDF no válido: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): se escribe con un UserUnit de {unit}, así que las aplicaciones que ignoran UserUnit la muestran ese número de veces más pequeña +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): se escribe con un UserUnit de {unit}, así que las aplicaciones que ignoran UserUnit la muestran ese número de veces más pequeña: activa «Escribir las páginas de más de 200 in a tamaño real, en puntos» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): las páginas {label} se escriben sin UserUnit, en puntos, y las aplicaciones que respetan el límite recortan la página a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para conservar su tamaño: el archivo se escribe como PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para no superar el límite de página de PDF/A-2, de 14.400 unidades: el archivo se escribe como PDF 1.6 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index db90a36316..3fb6e628ef 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4802,6 +4802,7 @@ imageTrace Outline Ääriviiva @msg internal error: {msg} (please report this bug) sisäinen virhe: {msg} (ilmoita tästä virheestä) @msg invalid PDF setting: {0} virheellinen PDF-asetus: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: se kirjoitetaan UserUnit-arvolla {unit}, joten UserUnitin ohittavat sovellukset näyttävät sen niin monta kertaa pienempänä +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: se kirjoitetaan UserUnit-arvolla {unit}, joten UserUnitin ohittavat sovellukset näyttävät sen niin monta kertaa pienempänä: ota käyttöön ”Kirjoita yli 200 in pitkät sivut täysikokoisina pisteinä” @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: {label}-sivut kirjoitetaan ilman UserUnitia, pisteinä, ja rajaa noudattavat sovellukset rajaavat sivun 200 in:iin @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee säilyttääkseen kokonsa: tiedosto kirjoitetaan PDF 1.6:na @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee pysyäkseen PDF/A-2:n 14 400 yksikön sivurajan sisällä: tiedosto kirjoitetaan PDF 1.6:na diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 938e43dd48..c6d9bc1836 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4575,6 +4575,7 @@ axis Both Les deux @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 the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : elle est écrite avec une UserUnit de {unit}, donc les applications qui ignorent UserUnit l’affichent autant de fois plus petite +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : elle est écrite avec une UserUnit de {unit}, donc les applications qui ignorent UserUnit l’affichent autant de fois plus petite : activez « Écrire les pages de plus de 200 in en taille réelle, en points » @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : les pages {label} sont écrites sans UserUnit, en points, et les applications qui respectent la limite coupent la page à 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour garder sa taille : le fichier est écrit en PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour rester dans la limite de page de PDF/A-2, 14 400 unités : le fichier est écrit en PDF 1.6 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0eacf9629b..1d22b0ffd9 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6969,6 +6969,7 @@ antiAlias Sharp शार्प @msg invalid pattern tile choice अमान्य पैटर्न टाइल ऑप्शन @msg invalid PDF setting: {0} Invalid PDF सेटिंग: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं: "200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें" चालू करें @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: {label} पेज बिना UserUnit के, पॉइंट में लिखे जाते हैं, और सीमा मानने वाले ऐप पेज को 200 in पर काट देते हैं @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को अपना आकार बनाए रखने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को PDF/A-2 की 14,400 इकाइयों की पेज सीमा के भीतर रहने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3dadc4d3b6..e68f7f5995 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5228,6 +5228,7 @@ imageTrace Outline Körvonal @msg invalid parameters for `{cmd}`: {msg} érvénytelen paraméterek (`{cmd}`): {msg} @msg invalid PDF setting: {0} érvénytelen PDF-beállítás: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: {unit} UserUnit értékkel íródik, így a UserUnit-ot figyelmen kívül hagyó alkalmazások ennyiszer kisebbnek mutatják +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: {unit} UserUnit értékkel íródik, így a UserUnit-ot figyelmen kívül hagyó alkalmazások ennyiszer kisebbnek mutatják: kapcsolja be a „200 in-nél hosszabb oldalak írása teljes méretben, pontban” beállítást @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: a(z) {label} oldalak UserUnit nélkül, pontokban íródnak, és a korlátot betartó alkalmazások 200 in-re vágják az oldalt @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 a PDF 1.5-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van a mérete megtartásához: a fájl PDF 1.6-ként íródik @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 a PDF 1.4-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van, hogy a PDF/A-2 14 400 egységes oldalkorlátján belül maradjon: a fájl PDF 1.6-ként íródik diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 91d1e24bf7..05ec99ac31 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4639,6 +4639,7 @@ axis Both Entrambi @msg internal error: {msg} (please report this bug) errore interno: {msg} (segnala questo bug) @msg invalid PDF setting: {0} impostazione PDF non valida: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): viene scritta con una UserUnit di {unit}, quindi le app che ignorano UserUnit la mostrano altrettante volte più piccola +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): viene scritta con una UserUnit di {unit}, quindi le app che ignorano UserUnit la mostrano altrettante volte più piccola: attiva «Scrivi le pagine più lunghe di 200 in a grandezza reale, in punti» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): le pagine {label} vengono scritte senza UserUnit, in punti, e le app che rispettano il limite tagliano la pagina a 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per mantenere le sue dimensioni: il file viene scritto come PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per restare entro il limite di pagina di PDF/A-2, di 14.400 unità: il file viene scritto come PDF 1.6 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 628d1ef3de..1350eebad9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4797,6 +4797,7 @@ swatch Bright Violet ビビッドバイオレット @msg cannot read PDF: {0} PDF を読み込めません: {0} @msg invalid PDF setting: {0} PDF の設定が正しくありません: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: UserUnit {unit} で書き出されるため、UserUnit を無視するアプリではその倍率だけ小さく表示されます +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: UserUnit {unit} で書き出されるため、UserUnit を無視するアプリではその倍率だけ小さく表示されます: 「200 in より長いページを実寸のポイントで書き出す」をオンにしてください @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: {label} のページは UserUnit なしでポイント単位で書き出され、上限を守るアプリではページが 200 in で切り取られます @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 には、14,400 pt (200 in) を超えるページがサイズを保つために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 には、14,400 pt (200 in) を超えるページが PDF/A-2 のページ上限 14,400 単位に収まるために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index aa2c27c296..9c259dfa00 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4272,6 +4272,7 @@ font-technology Variable ცვლადი @msg internal error: {msg} (please report this bug) შიდა შეცდომა: {msg} (გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) @msg invalid PDF setting: {0} PDF-ის არასწორი პარამეტრი: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ: ჩართეთ „200 in-ზე გრძელი გვერდების სრული ზომით, პუნქტებში ჩაწერა“ @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს PDF/A-2-ის 14 400 ერთეულიანი გვერდის ზღვარში დასარჩენად სჭირდება: ფაილი იწერება როგორც PDF 1.6 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5c2ac8d1..5a7fbfed92 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4724,6 +4724,7 @@ theme Appearance 외관 @msg cannot read PDF: {0} PDF를 읽을 수 없습니다: {0} @msg invalid PDF setting: {0} 잘못된 PDF 설정: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: UserUnit {unit}(으)로 기록되므로 UserUnit을 무시하는 앱에서는 그 배수만큼 작게 표시됩니다 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: UserUnit {unit}(으)로 기록되므로 UserUnit을 무시하는 앱에서는 그 배수만큼 작게 표시됩니다: "200 in보다 긴 페이지를 실제 크기의 포인트로 기록"을 켜세요 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: {label} 페이지는 UserUnit 없이 포인트 단위로 기록되며, 한도를 지키는 앱은 페이지를 200 in으로 자릅니다 @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5에는 14,400 pt (200 in)보다 긴 페이지가 크기를 유지하는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4에는 14,400 pt (200 in)보다 긴 페이지가 PDF/A-2의 페이지 한도인 14,400 단위 안에 들어가는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d46d7e8e87..c131cb7205 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -169,6 +169,7 @@ @msg the Simplify slider is not open de schuifregelaar Vereenvoudigen is niet geopend @msg the Smooth slider is not open de schuifregelaar Gladmaken is niet geopend @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller de pagina van tekengebied {n} is {size}, langer dan de paginalimiet van PDF van 14.400 pt (200 in): hij wordt geschreven met een UserUnit van {unit}, dus apps die UserUnit negeren tonen hem zoveel keer kleiner +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points de pagina van tekengebied {n} is {size}, langer dan de paginalimiet van PDF van 14.400 pt (200 in): hij wordt geschreven met een UserUnit van {unit}, dus apps die UserUnit negeren tonen hem zoveel keer kleiner: schakel 'Pagina's langer dan 200 in op ware grootte in punten schrijven' in @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in de pagina van tekengebied {n} is {size}, langer dan de paginalimiet van PDF van 14.400 pt (200 in): {label}-pagina’s worden zonder UserUnit in punten geschreven, en apps die zich aan de limiet houden snijden de pagina af op 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 heeft geen UserUnit, die een pagina langer dan 14.400 pt (200 in) nodig heeft om zijn formaat te behouden: het bestand wordt geschreven als PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 heeft geen UserUnit, die een pagina langer dan 14.400 pt (200 in) nodig heeft om binnen de paginalimiet van PDF/A-2 van 14.400 eenheden te blijven: het bestand wordt geschreven als PDF 1.6 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 38d5e2f497..356eaedd2e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4720,6 +4720,7 @@ imageTrace Outline Kontur @msg internal error: {msg} (please report this bug) błąd wewnętrzny: {msg} (zgłoś ten błąd) @msg invalid PDF setting: {0} nieprawidłowe ustawienie PDF: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): jest zapisywana z UserUnit {unit}, więc aplikacje ignorujące UserUnit pokazują ją tyle razy mniejszą +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): jest zapisywana z UserUnit {unit}, więc aplikacje ignorujące UserUnit pokazują ją tyle razy mniejszą: włącz „Zapisuj strony dłuższe niż 200 in w pełnym rozmiarze, w punktach” @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): strony {label} są zapisywane bez UserUnit, w punktach, a aplikacje przestrzegające limitu przycinają stronę do 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zachować rozmiar: plik jest zapisywany jako PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zmieścić się w limicie strony PDF/A-2 wynoszącym 14 400 jednostek: plik jest zapisywany jako PDF 1.6 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2a45bc8e68..198f1262fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4045,6 +4045,7 @@ axis Both Ambos @msg internal error: {msg} (please report this bug) erro interno: {msg} (relate este bug) @msg invalid PDF setting: {0} configuração de PDF inválida: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): ela é gravada com um UserUnit de {unit}, então apps que ignoram UserUnit a mostram essa quantidade de vezes menor +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): ela é gravada com um UserUnit de {unit}, então apps que ignoram UserUnit a mostram essa quantidade de vezes menor: ative "Gravar páginas com mais de 200 in em tamanho real, em pontos" @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): páginas {label} são gravadas sem UserUnit, em pontos, e apps que respeitam o limite cortam a página em 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 o PDF 1.5 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para manter seu tamanho: o arquivo é gravado como PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 o PDF 1.4 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para ficar dentro do limite de página do PDF/A-2, de 14.400 unidades: o arquivo é gravado como PDF 1.6 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af93b86974..421108d038 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4624,6 +4624,7 @@ axis Both Оба @msg internal error: {msg} (please report this bug) внутренняя ошибка: {msg} (пожалуйста, сообщите об этой ошибке) @msg invalid PDF setting: {0} недопустимый параметр PDF: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): она записывается с UserUnit {unit}, поэтому приложения, которые игнорируют UserUnit, показывают её во столько же раз меньше +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): она записывается с UserUnit {unit}, поэтому приложения, которые игнорируют UserUnit, показывают её во столько же раз меньше: включите «Записывать страницы длиннее 200 in в полном размере, в пунктах» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): страницы {label} записываются без UserUnit, в пунктах, и приложения, соблюдающие ограничение, обрезают страницу до 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 в PDF 1.5 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы сохранить размер: файл записывается как PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 в PDF 1.4 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы уложиться в предел страницы PDF/A-2 в 14 400 единиц: файл записывается как PDF 1.6 diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f26438a201..5f038eb085 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4488,6 +4488,7 @@ axis Both Båda @msg internal error: {msg} (please report this bug) internt fel: {msg} (rapportera gärna felet) @msg invalid PDF setting: {0} ogiltig PDF-inställning: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): den skrivs med en UserUnit på {unit}, så appar som ignorerar UserUnit visar den så många gånger mindre +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): den skrivs med en UserUnit på {unit}, så appar som ignorerar UserUnit visar den så många gånger mindre: slå på ”Skriv sidor längre än 200 in i full storlek i punkter” @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): {label}-sidor skrivs utan UserUnit, i punkter, och appar som håller sig till gränsen beskär sidan till 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att behålla sin storlek: filen skrivs som PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att hålla sig inom PDF/A-2:s sidgräns på 14 400 enheter: filen skrivs som PDF 1.6 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a91aab6f0e..49aeaa5d9d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4346,6 +4346,7 @@ axis Both Her İkisi @msg internal error: {msg} (please report this bug) dahili hata: {msg} (lütfen bu hatayı bildirin) @msg invalid PDF setting: {0} geçersiz PDF ayarı: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {unit} UserUnit değeriyle yazılır, bu yüzden UserUnit'i yok sayan uygulamalar onu o kadar kat küçük gösterir +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {unit} UserUnit değeriyle yazılır, bu yüzden UserUnit'i yok sayan uygulamalar onu o kadar kat küçük gösterir: "200 in'den uzun sayfaları tam boyutta, punto olarak yaz" seçeneğini açın @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {label} sayfaları UserUnit olmadan, punto cinsinden yazılır ve sınıra uyan uygulamalar sayfayı 200 in'de keser @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın boyutunu koruması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın PDF/A-2'nin 14.400 birimlik sayfa sınırı içinde kalması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17ddeb2086..8ec413d0a4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4623,6 +4623,7 @@ axis Both Обидві @msg internal error: {msg} (please report this bug) внутрішня помилка: {msg} (будь ласка, повідомте про цю помилку) @msg invalid PDF setting: {0} неприпустимий параметр PDF: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): її записано з UserUnit {unit}, тому застосунки, які ігнорують UserUnit, показують її в стільки ж разів меншою +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): її записано з UserUnit {unit}, тому застосунки, які ігнорують UserUnit, показують її в стільки ж разів меншою: увімкніть «Записувати сторінки, довші за 200 in, у повному розмірі, у пунктах» @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): сторінки {label} записуються без UserUnit, у пунктах, і застосунки, що дотримуються обмеження, обрізають сторінку до 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 у PDF 1.5 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб зберегти розмір: файл записується як PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 у PDF 1.4 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб вкластися в межу сторінки PDF/A-2 у 14 400 одиниць: файл записується як PDF 1.6 diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7d3d89a82d..aabcecfebf 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4881,6 +4881,7 @@ theme Appearance Giao diện @msg invalid parameters for `{cmd}`: {msg} tham số không hợp lệ cho `{cmd}`: {msg} @msg invalid PDF setting: {0} cài đặt PDF không hợp lệ: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): trang được ghi với UserUnit là {unit}, nên các ứng dụng bỏ qua UserUnit sẽ hiển thị nó nhỏ đi bấy nhiêu lần +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): trang được ghi với UserUnit là {unit}, nên các ứng dụng bỏ qua UserUnit sẽ hiển thị nó nhỏ đi bấy nhiêu lần: hãy bật "Ghi các trang dài hơn 200 in ở kích thước đầy đủ, theo điểm" @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): các trang {label} được ghi không có UserUnit, theo đơn vị điểm, và các ứng dụng tuân theo giới hạn sẽ cắt trang ở 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để giữ kích thước: tệp được ghi dưới dạng PDF 1.6 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để nằm trong giới hạn trang 14.400 đơn vị của PDF/A-2: tệp được ghi dưới dạng PDF 1.6 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 34ffeb5d6b..40527b3648 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4486,6 +4486,7 @@ imageTrace Outline 轮廓 @msg cannot read PDF: {0} 无法读取 PDF:{0} @msg invalid PDF setting: {0} PDF 设置无效:{0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):它以 UserUnit {unit} 写入,因此忽略 UserUnit 的应用会将其显示为缩小相应倍数 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):它以 UserUnit {unit} 写入,因此忽略 UserUnit 的应用会将其显示为缩小相应倍数:请打开“以实际尺寸(点)写入长于 200 in 的页面” @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):{label} 页面不带 UserUnit,以点为单位写入,遵守上限的应用会将页面裁切为 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持尺寸:文件将以 PDF 1.6 写入 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持在 PDF/A-2 的 14,400 单位页面上限之内:文件将以 PDF 1.6 写入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 309e00346c..bba50acebe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2236,6 +2236,7 @@ file-filter Templates 範本 @msg cannot read PDF: {0} 無法讀取 PDF:{0} @msg invalid PDF setting: {0} PDF 設定無效:{0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):它以 UserUnit {unit} 寫入,因此忽略 UserUnit 的應用程式會將其顯示為縮小相應倍數 +@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):它以 UserUnit {unit} 寫入,因此忽略 UserUnit 的應用程式會將其顯示為縮小相應倍數:請開啟「以實際尺寸(點)寫入長於 200 in 的頁面」 @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):{label} 頁面不含 UserUnit,以點為單位寫入,遵守上限的應用程式會將頁面裁切為 200 in @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持尺寸:檔案將以 PDF 1.6 寫入 @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持在 PDF/A-2 的 14,400 單位頁面上限之內:檔案將以 PDF 1.6 寫入 diff --git a/docs/mcp.md b/docs/mcp.md index 2b20cfc588..48f5ef7e07 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1080,7 +1080,10 @@ ignore `UserUnit` show such a page at its full size. The option applies only to files without a standard: PDF/X files write such a page in points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13), a PDF/A file at PDF 1.4 included (written as PDF 1.6). The dialog disables the -checkbox when a standard is chosen. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can +checkbox when a standard is chosen. In a PDF without a standard (the option +off, PDF 1.5 or later), the warning for a page written with a `UserUnit` above 1 ends by naming the option; in PDF/A +files and `.ai` saves it does not. +In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is flattened, and one whose art needs a real number past 32,767 fails with the same error when the file is written. A page whose short side is 3 pt or longer but would be under 3 units at that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7782bdfcd..6946577caf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1624,6 +1624,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. + In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`). A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`). PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). From dd5a9e7cbc72df0fa211caea392c2bc4278a961c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:12:22 -0800 Subject: [PATCH 09/27] Text Wrap: Invert Wrap intersects a line's spans in one pass With Invert Wrap, each piece of a line's band intersected the line's spans so far with the spans the wrap shape holds over that piece by comparing every pair of them. A wrap shape of many parts (a comb or a zigzag) gives a line many spans, and with an offset the band is cut into up to 128 pieces, so a dense shape made layout quadratic in the number of parts for every line. intersect_intervals now walks the two sorted lists once, stepping past whichever interval ends first, and gives the same spans in the same order. Tests compare it with the pairwise intersection on every list of non-overlapping intervals with ends among seven points, check that two interleaved lists of 3,000 and 2,000 intervals take at most 5,000 steps, and lay out a line inside a 500-tooth Invert Wrap comb with an offset. --- crates/text/src/layout/region.rs | 104 ++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index d7293c1109..7624c36ccb 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -166,20 +166,36 @@ fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { out } -/// `a` ∩ `b`. +/// `a` ∩ `b` (both sorted left to right, each interval starting no earlier than the one before it +/// ends, as [`union_intervals`] and [`band_intervals`] give them), left to right. It walks both +/// lists once, stepping past whichever current interval ends first (both +/// when they end together), so it looks at no more than `a.len() + b.len()` pairs. fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { let mut out = vec![]; - for &(s, e) in a { - for &(c, d) in b { - let (x, y) = (s.max(c), e.min(d)); - if y > x { - out.push((x, y)); - } + let (mut i, mut j) = (0, 0); + while let (Some(&(s, e)), Some(&(c, d))) = (a.get(i), b.get(j)) { + #[cfg(test)] + INTERSECT_PAIRS.set(INTERSECT_PAIRS.get() + 1); + let (x, y) = (s.max(c), e.min(d)); + if y > x { + out.push((x, y)); + } + // Ends that are equal, or not numbers, step past both, so the walk always ends. + match e.partial_cmp(&d) { + Some(std::cmp::Ordering::Less) => i += 1, + Some(std::cmp::Ordering::Greater) => j += 1, + _ => (i, j) = (i + 1, j + 1), } } out } +#[cfg(test)] +thread_local! { + /// How many pairs of intervals [`intersect_intervals`] looked at on this thread. + static INTERSECT_PAIRS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The most pairs of edges [`slant_boundaries`] compares for one band. const MAX_EDGE_PAIRS: usize = 1 << 14; @@ -792,6 +808,80 @@ fn an_inset_wider_than_a_shaped_frame_leaves_no_room() { } } +/// `a` ∩ `b` by comparing every interval of `a` with every interval of `b`: the result +/// [`intersect_intervals`] is checked against. +#[cfg(test)] +fn intersect_every_pair(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + a.iter().flat_map(|&(s, e)| b.iter().map(move |&(c, d)| (s.max(c), e.min(d)))).filter(|(x, y)| y > x).collect() +} + +/// The intersection of two lists of intervals, each sorted with no two overlapping, is the one +/// every pair of their intervals gives, in the same order, left to right and of positive width, for +/// every pair of such lists whose ends are among the points 0 to 6: empty lists, intervals that +/// only touch (in one list or across the two), that end together, that hold one another and that +/// overlap several others. +#[cfg(test)] +#[test] +fn intervals_intersect_as_every_pair_of_them_does() { + // Every list is a shorter list with one more interval after its last. + let mut lists: Vec> = vec![vec![]]; + let mut k = 0; + while let Some(list) = lists.get(k).cloned() { + let from = list.last().map_or(0, |l: &(f64, f64)| l.1 as u8); + for s in from..6 { + for e in s + 1..=6 { + let mut next = list.clone(); + next.push((f64::from(s), f64::from(e))); + lists.push(next); + } + } + k += 1; + } + assert_eq!(lists.len(), 233); + for a in &lists { + for b in &lists { + let got = intersect_intervals(a, b); + assert_eq!(got, intersect_every_pair(a, b), "{a:?} ∩ {b:?}"); + assert!(got.iter().all(|(x, y)| y > x) && got.windows(2).all(|w| w[0].1 <= w[1].0), "{got:?}"); + } + } +} + +/// Intersecting two unions of 3,000 and 2,000 intervals that interleave looks at no more than +/// 5,000 pairs of intervals, not every one of the 6,000,000 pairs. +#[cfg(test)] +#[test] +fn intersecting_intervals_looks_at_each_interval_once() { + let a: Vec<(f64, f64)> = (0..3000).map(|i| f64::from(i) * 4.0).map(|x| (x, x + 3.0)).collect(); + let b: Vec<(f64, f64)> = (0..2000).map(|i| f64::from(i) * 6.0).map(|x| (x + 1.0, x + 5.0)).collect(); + INTERSECT_PAIRS.set(0); + let got = intersect_intervals(&a, &b); + assert!(INTERSECT_PAIRS.get() <= a.len() + b.len(), "{} pairs", INTERSECT_PAIRS.get()); + assert_eq!(got, intersect_every_pair(&a, &b)); + assert!(!got.is_empty()); +} + +/// A line in area type with an Invert Wrap comb of 500 teeth and an offset keeps a span inside +/// each tooth, left to right, through every piece of its band, and intersecting the spans with each +/// piece looks at each span once. +#[cfg(test)] +#[test] +fn a_line_inside_an_invert_wrap_comb_keeps_a_span_in_each_tooth() { + let frame = Rect::new(0.0, 0.0, 1000.0, 100.0).to_path(0.1); + let teeth: Vec> = + (0..500).map(|k| f64::from(k) * 2.0).map(|x| vec![(x, 0.0), (x + 1.0, 0.0), (x + 1.0, 100.0), (x, 100.0)]).collect(); + let teeth: Vec<&[(f64, f64)]> = teeth.iter().map(Vec::as_slice).collect(); + let path = PathData::from_bezpath(&closed(&teeth)); + let wrap = vectorcraft_doc::WrapShape { path, wrap: vectorcraft_doc::TextWrap { offset: 0.25, invert: true } }; + let cells = Region::cells(&frame, &LayoutOptions::default(), &[wrap]); + INTERSECT_PAIRS.set(0); + let got = cells[0].spans(40.0, 60.0); + let want: Vec<(f64, f64)> = (0..500).map(|k| f64::from(k) * 2.0).map(|x| ((x - 0.25).max(0.0), x + 1.25)).collect(); + assert_spans(&got, &want); + // At most 64 pieces, each looking at no more than the spans so far and the tooth spans. + assert!(INTERSECT_PAIRS.get() <= 64 * (500 + 500), "{} pairs", INTERSECT_PAIRS.get()); +} + /// Along the line, text keeps the inset beside a wrap object, around it and, with Invert Wrap, /// inside it. #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..b189f2f4db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1266,6 +1266,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). + With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. From 50e6fa57fc952b8c32499adeb6c17b03d292db21 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:32:14 -0800 Subject: [PATCH 10/27] fmt --- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index be3461abe9..dbceed5881 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -263,7 +263,14 @@ fn long_page_notes_are_translated() { let warnings = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()); notes.extend(warnings.filter(|w| w.contains("too long and narrow")).map(str::to_string)); assert_eq!(notes.len(), 5, "{notes:?}"); - let english = ["has no UserUnit, which", "apps that ignore UserUnit", "apps that keep to the limit", "real numbers up to", "too long and narrow", "turn on"]; + let english = [ + "has no UserUnit, which", + "apps that ignore UserUnit", + "apps that keep to the limit", + "real numbers up to", + "too long and narrow", + "turn on", + ]; for code in ["de", "fr", "ja", "zh-hans"] { let lang = crate::i18n::Lang::from_code(code).unwrap(); for note in ¬es { From 9d9e0c2f8e6274c17163815c24ccf268eb58c5cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:38:00 -0800 Subject: [PATCH 11/27] SVG import: broken nested SVG images do not use up the image allowance An SVG shown as an image that could not be read (text that isn't XML, an SVG without a size) still took one of the 256 nested SVG images an SVG may show, so an image after 256 broken ones was left out. Only images that decode now count toward that limit; a failed image gives its place back. Every nested SVG image read, decoded or not, counts toward new limits of 1,024 reads and 512 MB of text per import, so a broken image shown many times through is still read a limited number of times. Past them, nested SVG images are left out with a warning. A nested SVG image with text that usvg then refuses no longer warns that its text isn't imported. --- crates/svg/src/import.rs | 2 +- crates/svg/src/import/files.rs | 80 ++++++++++++++++++++++++-- crates/svg/src/lib.rs | 2 +- crates/svg/tests/it/import_details.rs | 83 ++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 5 files changed, 161 insertions(+), 7 deletions(-) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 897f3fb5cf..087512bf90 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -20,7 +20,7 @@ use text::TextSlots; pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; -pub use files::MAX_NESTED_SVG_IMAGES; +pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; pub(crate) use fx::BLEND; /// Most objects an imported SVG may expand to: a guard against `` and nested-image diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 0472bc83e9..bab4246270 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -23,6 +23,13 @@ use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError}; /// Most nested SVG images an imported SVG may decode (#3564). pub const MAX_NESTED_SVG_IMAGES: usize = 256; +/// Most nested SVG images an imported SVG may read, decoded or not: each one read is checked and +/// parsed, and an image that fails is read again wherever it is shown. +pub const MAX_NESTED_SVG_READS: usize = 1024; + +/// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together. +pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; + /// The files an SVG's images link to. #[derive(Default)] pub(in crate::import) struct Files { @@ -48,6 +55,12 @@ pub(in crate::import) struct Files { svg_images: AtomicUsize, /// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`]. too_many_svg_images: AtomicBool, + /// How many nested SVG images were read so far, decoded or not. + svg_reads: AtomicUsize, + /// The bytes of text those reads took. + svg_text: AtomicU64, + /// SVG images were left out for exceeding [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`]. + too_much_svg_read: AtomicBool, /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, @@ -253,6 +266,12 @@ impl Files { if self.too_many_svg_images.load(Ordering::Relaxed) { notes.push(format!("SVG images were left out: more than {MAX_NESTED_SVG_IMAGES} nested SVG images")); } + if self.too_much_svg_read.load(Ordering::Relaxed) { + notes.push(format!( + "SVG images were left out: nested SVG images, valid or not, are read at most {MAX_NESTED_SVG_READS} times and {} MB of text", + MAX_NESTED_SVG_TEXT >> 20 + )); + } notes } @@ -308,11 +327,33 @@ impl Files { /// The SVG image `text` as usvg reads it with `opts`. `None`, the image left out as one usvg /// can't read, when it isn't SVG or when its elements nest deeper than the SVGs around it - /// leave: usvg reads it while it reads them, on the same stack. + /// leave: usvg reads it while it reads them, on the same stack. Only a decoded image, with the + /// images decoded inside it, counts toward [`MAX_NESTED_SVG_IMAGES`]; every image read counts + /// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`]. fn svg(&self, text: &str, opts: &usvg::Options) -> Option { - if self.svg_images.fetch_add(1, Ordering::Relaxed) >= MAX_NESTED_SVG_IMAGES { + let decoded = self.svg_images.load(Ordering::Relaxed); + if decoded >= MAX_NESTED_SVG_IMAGES { return leave_out(&self.too_many_svg_images, true); } + let reads = self.svg_reads.load(Ordering::Relaxed); + let read = self.svg_text.load(Ordering::Relaxed).saturating_add(text.len() as u64); + if reads >= MAX_NESTED_SVG_READS || read > MAX_NESTED_SVG_TEXT { + return leave_out(&self.too_much_svg_read, true); + } + self.svg_reads.store(reads + 1, Ordering::Relaxed); + self.svg_text.store(read, Ordering::Relaxed); + // The image's place is taken while usvg reads it, so the images inside it get what is left. + // usvg reads them all on this thread, so no other read comes between these steps. + self.svg_images.store(decoded + 1, Ordering::Relaxed); + let tree = self.svg_tree(text, opts); + if tree.is_none() { + self.svg_images.store(decoded, Ordering::Relaxed); + } + tree + } + + /// [`Files::svg`]'s image once it is counted. + fn svg_tree(&self, text: &str, opts: &usvg::Options) -> Option { // Measured as the SVG holding it is (`import`): the text before the XML reader. let used = self.depth.load(Ordering::Relaxed); let left = MAX_ELEMENT_DEPTH.saturating_sub(used); @@ -323,11 +364,12 @@ impl Files { let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; - self.note_text(text); self.depth.store(used + height, Ordering::Relaxed); let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts); self.depth.store(used, Ordering::Relaxed); - tree.ok().map(ImageKind::SVG) + let tree = tree.ok()?; + self.note_text(text); + Some(ImageKind::SVG(tree)) } } @@ -587,4 +629,34 @@ mod tests { assert!(!read("2147483647")); assert!(read("1")); } + + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of + /// text together: an image whose text goes past it is left out and noted. A broken image's + /// text counts too, and its decoded place is given back. + #[test] + fn svg_images_share_the_text_cap() { + let svg = r#""#; + let broken = &svg[..20]; + let files = Files::default(); + let resolver = files.resolver(1); + let opts = usvg::Options::default(); + let read = + |text: &str| matches!((resolver.resolve_data)("image/svg+xml", Arc::new(text.as_bytes().to_vec()), &opts), Some(ImageKind::SVG(_))); + assert!(!read(broken)); + assert_eq!(files.svg_text.load(Ordering::Relaxed), broken.len() as u64); + assert_eq!((files.svg_reads.load(Ordering::Relaxed), files.svg_images.load(Ordering::Relaxed)), (1, 0)); + files.svg_text.store(MAX_NESTED_SVG_TEXT - svg.len() as u64, Ordering::Relaxed); + assert!(read(svg)); + assert_eq!((files.svg_reads.load(Ordering::Relaxed), files.svg_images.load(Ordering::Relaxed)), (2, 1)); + assert!(files.left_out().is_empty()); + assert!(!read(svg) && !read(broken)); + assert_eq!(files.svg_text.load(Ordering::Relaxed), MAX_NESTED_SVG_TEXT, "past the cap, nothing is read"); + assert_eq!( + files.left_out(), + [format!( + "SVG images were left out: nested SVG images, valid or not, are read at most {MAX_NESTED_SVG_READS} times and {} MB of text", + MAX_NESTED_SVG_TEXT >> 20 + )] + ); + } } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7e5d220fbb..0c698eebc7 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -576,7 +576,7 @@ pub fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Resu } pub use glyph::glyph_art; -pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NODES}; +pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES}; /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index d6983a1ce2..b043e4d985 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; use vectorcraft_geom::Rect; -use vectorcraft_svg::{ImportOptions, MAX_NESTED_SVG_IMAGES, SvgError, import_with, import_with_report, import_within}; +use vectorcraft_svg::{ImportOptions, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, SvgError, import_with, import_with_report, import_within}; use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; fn svg(body: &str) -> String { @@ -683,6 +683,87 @@ fn nested_svg_images_past_count_cap_are_left_out() { assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } +/// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size. +const BROKEN_SVG_IMAGES: [&str; 2] = + [r#""#]; +const SMALL_SVG_IMAGE: &str = r#""#; + +/// `broken` images showing the broken SVG images in turn from data URLs, then `good` images +/// showing a rectangle. +fn broken_then_good(broken: usize, good: usize) -> String { + let broken: String = (0..broken).map(|i| image_of("image/svg+xml", BROKEN_SVG_IMAGES[i % 2])).collect(); + format!(r#"{broken}{}"#, image_of("image/svg+xml", SMALL_SVG_IMAGE).repeat(good)) +} + +/// Only nested SVG images that decode count toward `MAX_NESTED_SVG_IMAGES`: an image after that +/// many broken ones, from data URLs or a linked file shown again and again, is imported, and the +/// images past that many decoded ones are still left out. +#[test] +fn broken_nested_svg_images_do_not_use_up_the_image_allowance() { + let allowance = |w: &[String]| w.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))); + let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_IMAGES + 10, 1), &ImportOptions::default()).unwrap(); + assert_eq!(rects(&d), 1, "{w:?}"); + assert!(!w.iter().any(|w| w.contains("left out")), "{w:?}"); + let (d, w) = import_with(&broken_then_good(10, MAX_NESTED_SVG_IMAGES + 1), &ImportOptions::default()).unwrap(); + assert_eq!(rects(&d), MAX_NESTED_SVG_IMAGES, "{w:?}"); + assert!(allowance(&w), "{w:?}"); + + let dir = folder("brokensvgimages"); + std::fs::write(dir.join("img").join("broken.svg"), BROKEN_SVG_IMAGES[0]).unwrap(); + std::fs::write(dir.join("img").join("good.svg"), SMALL_SVG_IMAGE).unwrap(); + let reads = std::sync::atomic::AtomicUsize::new(0); + let counted = |path: &str| { + reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + read(path) + }; + let body = format!( + r#"{}"#, + r#""#.repeat(MAX_NESTED_SVG_IMAGES + 10) + ); + let folder = dir.to_string_lossy(); + let (d, w) = import_with(&svg(&body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap(); + assert_eq!(rects(&d), 1, "{w:?}"); + assert!(!w.iter().any(|w| w.contains("left out")), "{w:?}"); + assert_eq!(reads.into_inner(), 2, "each file is read once"); +} + +/// Every nested SVG image read, decoded or not, counts toward `MAX_NESTED_SVG_READS`: past it, +/// images are left out with a warning, also when `` shows one broken linked file many times. +#[test] +fn nested_svg_images_are_read_at_most_max_nested_svg_reads_times() { + let past = |w: &[String]| w.iter().any(|w| w.contains(&format!("read at most {MAX_NESTED_SVG_READS} times"))); + let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_READS - 1, 1), &ImportOptions::default()).unwrap(); + assert_eq!(rects(&d), 1, "{w:?}"); + assert!(!past(&w), "{w:?}"); + let (d, w) = import_with(&broken_then_good(MAX_NESTED_SVG_READS, 1), &ImportOptions::default()).unwrap(); + assert_eq!(rects(&d), 0, "{w:?}"); + assert!(past(&w), "{w:?}"); + assert!(!w.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))), "{w:?}"); + + let dir = folder("brokensvguses"); + std::fs::write(dir.join("img").join("broken.svg"), BROKEN_SVG_IMAGES[0]).unwrap(); + std::fs::write(dir.join("img").join("good.svg"), SMALL_SVG_IMAGE).unwrap(); + let uses = r##""##.repeat(2 * MAX_NESTED_SVG_READS); + let body = format!( + r#"{uses}"# + ); + let (d, w) = open_in(&dir, &body); + assert_eq!(rects(&d), 0, "{w:?}"); + assert!(past(&w), "{w:?}"); +} + +/// A nested SVG image that can't be read and is left out gives no warning about its text. +#[test] +fn broken_nested_svg_images_do_not_warn_about_their_text() { + let text = |svg: &str| { + let file = format!(r#"{}"#, image_of("image/svg+xml", svg)); + let (_, w) = import_with(&file, &ImportOptions::default()).unwrap(); + w.iter().any(|w| w.contains("text inside an SVG image")) + }; + assert!(!text(r#"Hi"#)); + assert!(text(r#"Hi"#)); +} + /// #3566: entity references in element content count toward the expansion limit, as in /// attributes. A 10 KB entity, a second entity that refers to it 250 times, and eight references /// to the second inside `` would expand to about 20 MB from a file of a few KB: the import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..8dcc7fe7a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1705,6 +1705,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). + - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session). From 427624546927620c7d4a74a679d43648af690397 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:39:13 -0800 Subject: [PATCH 12/27] Path Precision Tools: group selected segments and crossings by subpath in one pass Add Points and Divide into Dashes read each subpath's selected segments with a range lookup in the path's ordered segment set instead of filtering the whole set once per subpath. Add Points at Crossings and Split at Crossings sort a path's crossing places into a list per subpath once, in the order they come, instead of filtering the whole list for every subpath. Places on a subpath that no longer exists are still left out. The results are unchanged. Tests cover paths with several subpaths, an empty subpath, an untouched subpath and selection on the last subpath, for all four commands, plus the anchor fallback and the grouping of out-of-order crossing places. --- crates/engine/src/cmd/addons/pathtools.rs | 17 +- .../engine/src/cmd/addons/pathtools/tests.rs | 177 +++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 9ddcc43559..5a38f73289 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -609,7 +609,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { let n = sp.anchors.len().max(1); match segments.filter(|set| !set.is_empty()) { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + Some(set) => super::super::path::subpath_refs(set, si).collect(), None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), } } @@ -681,6 +681,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result { // ---------- Split / Add Points at Crossings ---------- +/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in +/// the order they come in `on`. A place on a subpath past the last is left out. +fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec> { + let mut by_sub = vec![vec![]; subpaths]; + for &(si, seg, t) in on { + if let Some(here) = by_sub.get_mut(si) { + here.push((seg, t)); + } + } + by_sub +} + fn crossings(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.crossings"; let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?); @@ -702,8 +714,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result { } let mut subs = vec![]; let mut marks = vec![]; - for (si, sp) in path.subpaths.iter().enumerate() { - let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) { let (out, m) = pt::insert_at(sp, &here); count += m.len(); subs.push(out); diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..f98880e204 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; use vectorcraft_geom::shapes::CornerKind; -use vectorcraft_geom::{AnchorKind, PathData, Point}; +use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath}; use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::{Session, ViewInfo}; @@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() { refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]); } +/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an +/// empty subpath. +fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId { + let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false); + let pd = + PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), false)).collect()); + s.edit("Test", |d, _| { + super::set_path(d, id, pd); + Ok(()) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// The anchor points of each subpath of `pd`, rounded to a millionth. +fn points(pd: &PathData) -> Vec> { + let r = |v: f64| (v * 1e6).round() / 1e6; + pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect() +} + +/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last). +const SUBS: [&[(f64, f64)]; 4] = + [&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]]; + +/// Direct-select `segments` of `id` and the anchors `anchors`. +fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) { + s.select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, anchors.iter().copied().collect()); + sel.segments.insert(id, segments.iter().copied().collect()); + }) + .unwrap(); +} + +#[test] +fn picked_segments_reads_only_its_own_subpath() { + let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into(); + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false); + let all: BTreeSet = (0..4).collect(); + let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::>(); + assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]); + // Without direct-selected segments, the segments between two selected anchors. + let anchors: BTreeSet = [0, 1, 3].into(); + for none in [None, Some(&BTreeSet::new())] { + assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::>(), [0]); + } +} + +#[test] +fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() { + let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)]; + let by_sub = super::places_by_subpath(&on, 4); + assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]); + // The same places as filtering the whole list for each subpath. + for (si, here) in by_sub.iter().enumerate() { + let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + assert_eq!(*here, filtered); + } + assert!(super::places_by_subpath(&on, 0).is_empty()); +} + +#[test] +fn add_points_adds_to_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2)); + let want = vec![ + vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)], + vec![], + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)], + ]; + let after = path(&s, c); + assert_eq!(points(&after), want); + assert_eq!(steps(&s), n + 1); + let st = s.doc().unwrap(); + assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, c), after); + // Without selected segments: those between two selected anchors, here on the last subpath only. + for segments in [None, Some(BTreeSet::new())] { + s.execute("edit.undo", &json!({})).unwrap(); + s.select(|_, sel| { + sel.set([c]); + sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into()); + sel.segments.remove(&c); + if let Some(set) = segments.clone() { + sel.segments.insert(c, set); + } + }) + .unwrap(); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1)); + assert_eq!( + points(&path(&s, c)), + [points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat() + ); + } +} + +#[test] +fn crossings_add_anchors_on_each_subpath_of_a_compound_path() { + let mut s = session(); + // A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not. + let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]); + let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false); + let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false); + select(&mut s, &[c, q, r]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8)); + let want = vec![ + vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), want); + assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + // Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths. + let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap()); + assert_eq!(out.len(), 7); + assert_eq!(out[0], c); + let pieces = vec![ + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(100.0, 50.0), (150.0, 50.0)], + vec![(150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0)], + vec![(100.0, 250.0), (150.0, 250.0)], + vec![(150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), pieces); + let qs: Vec>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect(); + assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]); + assert_eq!(steps(&s), n + 1); +} + // ---------- Parametric Shape Tool ---------- /// The parametric shape object `id` is now, as the tool reads it. @@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() { assert_eq!(layer, pieces, "in the path's place, in order"); } +#[test] +fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(r["dashes"], json!(8)); + let made: Vec>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect(); + let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]]; + let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]]; + want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0))); + want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]); + want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]); + want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0))); + assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order"); + assert_eq!(s.doc().unwrap().selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + // With no segments selected: those between two selected anchors, here on the last subpath only. + select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4))); + assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]); +} + #[test] fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { let mut s = session(); From ab1b7a974a8461dbafe3007bfb1814feb51bf7d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:40:31 -0800 Subject: [PATCH 13/27] Type: threaded type on a path aligned Justify All is measured by paragraphs Type on a path whose first paragraph is aligned Justify All places its line from the start bracket, the same as Left, so a threaded story is now measured with the paragraphs it needs rather than the whole rest. A 1,500-paragraph story through 40 paths aligned Justify All was laid out over 2,135,202 bytes while measuring and is now laid out over 20,583. The frames take the same text as before. Center, Right, Auto right to left, hanging punctuation, Dynamic Text and horizontal right-to-left text are still measured with the whole rest: a centered or right-aligned line starts where the width of all its glyphs puts it, and negative tracking can make an advance negative. Tests compare every frame's share with the whole-rest share for Justify All with spacing, on closed paths, with negative tracking and with punctuation ending paragraphs. --- crates/text/src/thread.rs | 13 ++++-- crates/text/src/thread/tests.rs | 82 ++++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index ff22f3a5e8..609f155999 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -279,10 +279,13 @@ impl<'a> Rest<'a> { /// /// Type on a path is measured the same way when its line starts at the start bracket whatever text /// follows: no Dynamic Text, Optical Margin Alignment or Roman Hanging Punctuation, the first -/// paragraph aligned Left, Justify Left or Auto, and no right-to-left text (`no_rtl`, and the first -/// paragraph's direction not right to left) unless the type is vertical. Each glyph's step along the -/// path then depends only on the glyphs before it, so when the first glyph that does not fit comes -/// before the last paragraph break measured, the path holds the same bytes with the whole rest. +/// paragraph aligned Left, Justify Left, Justify All or Auto, and no right-to-left text (`no_rtl`, and +/// the first paragraph's direction not right to left) unless the type is vertical. Each glyph's step +/// along the path then depends only on the glyphs before it, so when the first glyph that does not +/// fit comes before the last paragraph break measured, the path holds the same bytes with the whole +/// rest. A line aligned to the center or the right, or Auto right to left, starts where the width of +/// all its glyphs puts it, and a glyph's advance can be negative (negative tracking), so a shorter +/// text can start further along than the whole rest does; such type is measured with the whole rest. /// /// Other type, and a rest that ends first, is measured with the whole rest. fn fit_rest(db: &FontDb, probe: impl Fn(Vec) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize, no_rtl: bool) -> usize { @@ -313,7 +316,7 @@ fn path_starts_at_bracket(head: &TextObject, no_rtl: bool) -> bool { !head.dynamic_text && !head.optical_margin && !para.roman_hanging - && matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft) + && matches!(para.justify, Justify::Auto | Justify::Left | Justify::JustifyLeft | Justify::JustifyAll) && (head.vertical || (no_rtl && para.direction != Some(ParaDirection::RightToLeft))) } diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 7554a808f9..0ac64f5d27 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -27,6 +27,13 @@ fn curved_path_frame() -> TextObject { t } +/// Type on a closed path, a circle about 314 round. +fn closed_path_frame() -> TextObject { + let mut t = path_frame(300.0); + t.kind = TextKind::OnPath { path: shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), start: 0.0, end: None }; + t +} + /// Type on a path that overflows ends its line where its text stops fitting, as area type's /// last line does, so a story flows on from there through area type and type on a path alike. #[test] @@ -724,7 +731,7 @@ fn frames_take_the_shares_the_whole_rest_gives_them() { ] .concat(); type Edit = fn(&mut TextObject); - let edits: [(&str, Edit); 15] = [ + let edits: [(&str, Edit); 18] = [ ("path", |_| {}), ("path, Left", |t| t.para.justify = Justify::Left), ("path, Auto", |t| t.para.justify = Justify::Auto), @@ -732,6 +739,20 @@ fn frames_take_the_shares_the_whole_rest_gives_them() { ("path, Center", |t| t.para.justify = Justify::Center), ("path, Right", |t| t.para.justify = Justify::Right), ("path, Justify All", |t| t.para.justify = Justify::JustifyAll), + ("path, Justify All, spacing", |t| { + t.para.justify = Justify::JustifyAll; + t.path_spacing = 8.0; + }), + ("path, Justify All, negative spacing", |t| { + t.para.justify = Justify::JustifyAll; + t.path_spacing = -8.0; + }), + ("closed path, Justify All", |t| { + t.para.justify = Justify::JustifyAll; + if let TextKind::OnPath { path, .. } = &mut t.kind { + *path = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)); + } + }), ("path, spacing", |t| t.path_spacing = 8.0), ("path, Optical Margin Alignment", |t| t.optical_margin = true), ("path, Roman Hanging Punctuation", |t| t.para.roman_hanging = true), @@ -757,6 +778,35 @@ fn frames_take_the_shares_the_whole_rest_gives_them() { // The story's last character does not hang, the last one measured does. took += same_as_whole_rest(db, &refs(&on), &story(&ends_unhung), &[], &format!("{case}, story ending in a letter")); } + // Glyphs whose advance is negative, and punctuation and quotes that end paragraphs, some of + // them alone after empty paragraphs. + let tight = CharStyle { tracking: -1200.0, ..Default::default() }; + let text = paragraphs(120); + let mut negative: Vec = + text.split_inclusive(' ').enumerate().map(|(i, w)| TextRun::new(w, if i % 4 == 0 { tight.clone() } else { plain.clone() })).collect(); + normalize(&mut negative); + let punctuated = (1..=150) + .map(|i| match i % 3 { + 0 => ".".to_string(), + 1 => format!("Item {i}: the \u{201c}fox.\u{201d}"), + _ => String::new(), + }) + .collect::>() + .join("\n"); + for justify in [Justify::Left, Justify::JustifyAll, Justify::Center, Justify::Right] { + let mut on = paths(|_| {}); + on.push(closed_path_frame()); + for t in &mut on { + t.para.justify = justify; + } + took += same_as_whole_rest(db, &refs(&on), &negative, &[], &format!("path, {justify:?}, negative tracking")); + took += same_as_whole_rest(db, &refs(&on), &story(&punctuated), &[], &format!("path, {justify:?}, punctuation at paragraph ends")); + let mut spaced = on.clone(); + for t in &mut spaced { + t.path_spacing = -8.0; + } + took += same_as_whole_rest(db, &refs(&spaced), &negative, &[], &format!("path, {justify:?}, negative tracking and spacing")); + } let on = paths(|_| {}); took += same_as_whole_rest(db, &refs(&on), &styled, &[], "path, style runs"); took += same_as_whole_rest(db, &refs(&on), &story(¶graphs(120)), &listed, "path, lists"); @@ -877,7 +927,7 @@ fn many_frames_measure_about_the_text_they_take() { } measures_about_the_text_taken(db, &shaped, &format!("{align:?}, ellipses")); } - for justify in [Justify::Left, Justify::JustifyLeft, Justify::Auto] { + for justify in [Justify::Left, Justify::JustifyLeft, Justify::JustifyAll, Justify::Auto] { let paths: Vec = (0..40) .map(|_| { let mut t = path_frame(300.0); @@ -887,6 +937,34 @@ fn many_frames_measure_about_the_text_they_take() { .collect(); measures_about_the_text_taken(db, &paths, &format!("path, {justify:?}")); } + // Justify All on curved, bracketed and closed paths, and with Spacing. + type Make = fn() -> TextObject; + let kinds: [(&str, Make); 4] = [ + ("curved", curved_path_frame), + ("bracketed", || { + let mut t = path_frame(300.0); + if let TextKind::OnPath { start, end, .. } = &mut t.kind { + (*start, *end) = (0.1, Some(0.8)); + } + t + }), + ("closed", closed_path_frame), + ("spaced", || { + let mut t = curved_path_frame(); + t.path_spacing = -20.0; + t + }), + ]; + for (shape, make) in kinds { + let paths: Vec = (0..40) + .map(|_| { + let mut t = make(); + t.para.justify = Justify::JustifyAll; + t + }) + .collect(); + measures_about_the_text_taken(db, &paths, &format!("{shape} path, Justify All")); + } let vertical: Vec = (0..40) .map(|_| { let mut t = path_frame(300.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..9e999f71c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1283,7 +1283,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. On a shaped area type frame, Inset Spacing keeps text that far from the frame's edge, holes included, measured from the edge itself rather than along the line; the archived help treats the inset as the space an area type object leaves between its text and the path that bounds it (, ). A rectangular frame keeps its inset from each side as before, and a column's inner edges keep it once. Tests: `layout::region::inset_on_a_shaped_frame_is_measured_from_its_sloped_edge`, `inset_keeps_text_clear_of_a_hole_s_sloped_edges`, `inset_applies_once_at_a_column_s_edge_and_at_the_frame_s_edge`. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). - When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Area type with its lines aligned to the center, the bottom or justified vertically, and type on a path whose first paragraph is aligned Left, Justify Left or Auto, are measured the same way: the same story through 40 area frames aligned to the center was laid out over 2,126,762 bytes and is now laid out over 21,035, and through 40 paths aligned Left over 2,135,202 bytes and now over 20,583 (same test). Shrink Text to Fit, Dynamic Text, Optical Margin Alignment and Roman Hanging Punctuation on a path, a path aligned another way, horizontal type on a path whose first paragraph is set right to left or with right-to-left text in the rest of the story, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. + When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Area type with its lines aligned to the center, the bottom or justified vertically, and type on a path whose first paragraph is aligned Left, Justify Left, Justify All or Auto, are measured the same way: the same story through 40 area frames aligned to the center was laid out over 2,126,762 bytes and is now laid out over 21,035, through 40 paths aligned Left over 2,135,202 bytes and now over 20,583, and through 40 paths aligned Justify All over 2,135,202 bytes and now over 20,583 (same test). Shrink Text to Fit, Dynamic Text, Optical Margin Alignment and Roman Hanging Punctuation on a path, a path aligned to the center or the right, horizontal type on a path whose first paragraph is set right to left or with right-to-left text in the rest of the story, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. In threaded text, a paragraph format given to the empty paragraph after a frame's last paragraph break stays after the story flows again, in that frame and in the frames the paragraph continues into, unless the same edit also formats the next frame's first paragraph, whose format then applies (VectorCraft's own rule; `thread::tests::restyled_empty_last_paragraph_wins_over_unedited_next_frame`, `cmd::threads::tests::editing_a_frames_empty_last_paragraph_survives_thread_reflow`). Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. From 25857b2bce00bf74cc2b567eb80dc2a2237ce5c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:40:40 -0800 Subject: [PATCH 14/27] Type: right-to-left lines with tabs are measured as they are drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A right-to-left line draws the text before a tab after it, but its width was measured in the order of its text. A right-aligned line such as "אב גדהו זח" with a stop at 150 pt in a 300 pt frame ended 5.59 pt past the frame's right edge, and right, center and decimal stops aligned the text before the tab instead of the text drawn after it. - The line's width is measured in drawing order, each tab up to its stop from where the glyphs drawn before it end. Right, Auto and centered lines are aligned by that width. Stops are still measured from the left edge. - Right, center and decimal stops align the text drawn after the tab. - Area type breaks a line with a tab where it fits as drawn, and wraps a line that fits only in the order of its text. Once the line holds a tab, it reads on while the least it can be drawn at fits, then ends at the latest break that fits as drawn: up to 256 breaks are measured from the latest back, and earlier breaks without a hyphen are bisected. Hyphenation points are measured as drawn the same way, so the line hyphenates at a point that fits. - Only lines of horizontal paragraphs with right-to-left text keep a list of their break candidates. - A tab that ends a wrapped right-to-left line is drawn before the line at its own width instead of pushing the line to the right. Docs: docs/target-app-parity.md. --- crates/text/src/layout.rs | 2 +- crates/text/src/layout/breaking.rs | 147 ++++++++++--- crates/text/src/layout/flow.rs | 77 +++++-- crates/text/src/layout/wrapping_tests.rs | 258 ++++++++++++++++++++--- crates/text/src/tests/justify.rs | 128 +++++++++++ docs/target-app-parity.md | 1 + 6 files changed, 540 insertions(+), 73 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index fedd795d78..e487fb2cba 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -327,7 +327,7 @@ impl Ctx<'_> { let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); let to_indent = left - pen; let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS); - tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; + tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg.iter()).0 }; if para.list.is_some_and(|l| l.align == ListAlign::Right) { marker.insert(0, tab); } else { diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fd53a26428..601909d47a 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -203,11 +203,17 @@ impl Pen<'_> { /// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then /// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal -/// stops align the text that follows (`rest`, up to the next tab) on the stop. -pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { +/// stops align the text drawn after the tab (`rest`, in drawing order, up to the next tab) on the +/// stop. +pub(super) fn tab_advance<'t, 'g>( + tabs: &'t [vectorcraft_doc::TabStop], + origin: f64, + x: f64, + rest: impl IntoIterator, +) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; - let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); + let seg: Vec<&SGlyph> = rest.into_iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); let width: f64 = seg.iter().map(|g| g.adv).sum(); let target = |pos: f64, align: TabAlign, on: char| match align { TabAlign::Left => pos, @@ -227,17 +233,28 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, ((at - rel).max(0.0), leader) } -/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the -/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their -/// glyph. The tab of a list marker (length 0) keeps its own width. -pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { +/// The glyphs of `sg` drawn after position `k` of `drawn` (indices into `sg`, in drawing order). +pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize) -> impl Iterator { + drawn.iter().skip(k + 1).filter_map(move |&j| sg.get(j)) +} + +/// The width of the glyphs of `sg` at `drawn` (indices into `sg`, in the order the line draws +/// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take +/// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs +/// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its +/// own width. +pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; - for (j, g) in sg.iter().enumerate().take(to).skip(from) { - x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) { + x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv }; } x - x_start } +/// Most breaks, or hyphenation points, [`break_line`] measures as drawn one by one from the latest +/// back before bisecting the earlier ones. +const MAX_DRAWN_CHECKS: usize = 256; + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -335,6 +352,12 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. /// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster /// that fits only that narrower stays on the line when the line may end after it (empty: none). +/// `fits`, for a line drawn in another order than its text's (right to left), is whether the line +/// from `i` ending before glyph `end` (with a hyphen when `hyphenated`) fits `width` as it is drawn: +/// once the line holds a tab, whose width depends on what is drawn before it, the line reads on +/// while the least it can be drawn at fits `width`, then ends at the latest legal break where it +/// fits as drawn (past [`MAX_DRAWN_CHECKS`] breaks back from the latest, the earlier ones without a +/// hyphen are bisected); hyphenation points are measured the same way. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -350,7 +373,29 @@ pub(super) fn break_line( hanging: bool, vertical: bool, line_end: &[f64], + fits: Option<&dyn Fn(usize, bool) -> bool>, ) -> (usize, bool) { + // The line ending at `chosen`, once it holds a tab, when it fits as drawn; otherwise the latest + // of the legal `breaks` before it that fits as drawn, measuring at most MAX_DRAWN_CHECKS of + // them from the latest back and bisecting the earlier ones without a hyphen; the first of them + // when none of those measured fits. + let settle = |tabbed: bool, chosen: (usize, bool), breaks: &[(usize, bool)]| -> (usize, bool) { + let Some(fits) = fits.filter(|_| tabbed) else { return chosen }; + if fits(chosen.0, chosen.1) { + return chosen; + } + let earlier = breaks.get(breaks.partition_point(|b| b.0 <= i)..breaks.partition_point(|b| b.0 < chosen.0)).unwrap_or_default(); + if let Some(&b) = earlier.iter().rev().take(MAX_DRAWN_CHECKS).find(|b| fits(b.0, b.1)) { + return b; + } + // Without a hyphen, a line that ends later is drawn no narrower, so those breaks bisect. + let rest = earlier.get(..earlier.len().saturating_sub(MAX_DRAWN_CHECKS)).unwrap_or_default(); + let plain: Vec<&(usize, bool)> = rest.iter().filter(|b| !b.1).collect(); + let fitting = plain.partition_point(|b| fits(b.0, b.1)); + fitting.checked_sub(1).and_then(|k| plain.get(k).copied()).or(earlier.first()).copied().unwrap_or(chosen) + }; + let mut breaks: Vec<(usize, bool)> = Vec::new(); + let mut tabbed = false; if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { return (i + pos + 1, false); @@ -358,6 +403,27 @@ pub(super) fn break_line( return (g.len(), false); } let mut x = 0.0; + // The width of the line's glyphs other than its tabs, how far those with a negative advance step + // back in all, and how many tabs it holds. + let mut ink = 0.0; + let mut back = 0.0; + let mut tabs_read = 0usize; + // The `n`th of the stops a tab can go to, in order: the explicit stops, then every default + // stop after the last of them (0 when `n` is 0). + let mut explicit: Vec = if fits.is_some() { tabs.iter().map(|t| t.position).filter(|p| p.is_finite()).collect() } else { Vec::new() }; + explicit.sort_by(f64::total_cmp); + explicit.dedup(); + let last_explicit = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); + let nth_stop = |n: usize| -> f64 { + use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL; + match n.checked_sub(1) { + None => 0.0, + Some(k) => explicit + .get(k) + .copied() + .unwrap_or_else(|| ((last_explicit / DEFAULT_TAB_INTERVAL).floor() + (n - explicit.len()) as f64) * DEFAULT_TAB_INTERVAL), + } + }; let mut last_break: Option<(usize, bool)> = None; // A list marker (length 0) is a cluster of its own; the line holds it and at least the first // cluster after it. @@ -384,19 +450,32 @@ pub(super) fn break_line( } } // A list marker's glyphs, its tab included, keep their own widths. - let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 { + let is_tab = gl.ch == '\t' && gl.len > 0; + let cluster_width: f64 = if is_tab { + tabbed = true; + tabs_read += 1; tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() }; + let cluster_ink = if is_tab { 0.0 } else { cluster_width }; + let cluster_back: f64 = if is_tab { 0.0 } else { g[j..cluster_end].iter().map(|glyph| (-glyph.adv).max(0.0)).sum() }; if gl.ch == '\u{2028}' { - return (cluster_end, false); + return settle(tabbed, (cluster_end, false), &breaks); } // Measured as the line is set when it ends with this cluster: only the cluster's last glyph // is narrowed, and only where the line may end. let end_credit = Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0); - if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS { + // Once the line holds a tab and is measured as drawn, it reads on while the least it can be + // drawn at fits, and where it ends is settled then. That least is its glyphs other than the + // tabs and, while no glyph steps back, the stop its tabs reach: each tab then goes to a later + // stop than the one before and the text aligned on it ends at or past that stop, so the + // line's tabs, all before this cluster, reach at least the stop as many places along as + // there are tabs. + let stops_reached = if back + cluster_back > 0.0 { f64::NEG_INFINITY } else { nth_stop(tabs_read) - x_start }; + let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; + if j > first && !gl.is_space() && measured - end_credit > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; @@ -413,20 +492,23 @@ pub(super) fn break_line( break; } x += cluster_width; + ink += cluster_ink; + back += cluster_back; let last = &g[cluster_end - 1]; if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { - if last.is_soft_hyphen() { - if x + hyphen_glyph(last).adv <= width + EPS { - last_break = Some((cluster_end, true)); + // Once the line holds a tab, whether a break fits as drawn is settled when the line ends. + let hyphenated = last.is_soft_hyphen(); + if !hyphenated || (tabbed && fits.is_some()) || x + hyphen_glyph(last).adv <= width + EPS { + last_break = Some((cluster_end, hyphenated)); + if fits.is_some() { + breaks.push((cluster_end, hyphenated)); } - } else { - last_break = Some((cluster_end, false)); } } j = cluster_end; } if j >= g.len() { - return (g.len(), false); + return settle(tabbed, (g.len(), false), &breaks); } // Hyphenate the word that overflows. A list marker starting the line is not part of a word. let word_start = last_break.map_or(first, |b| b.0); @@ -450,22 +532,31 @@ pub(super) fn break_line( .into_iter() .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) .collect(); - for &k in pts.iter().rev() { - if k <= j && k > i { - let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; - if w <= width + EPS { - return (k, true); - } + // The points the line may end at, the latest first. Measured as drawn, at most + // MAX_DRAWN_CHECKS of them are measured one by one, and the earlier ones are bisected: + // each ends with a hyphen, so a later one is drawn no narrower. + let points: Vec = pts.iter().rev().copied().filter(|&k| k <= j && k > i).collect(); + let drawn = fits.filter(|_| tabbed); + for &k in points.iter().take(if drawn.is_some() { MAX_DRAWN_CHECKS } else { usize::MAX }) { + let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; + if drawn.map_or(w <= width + EPS, |fits| fits(k, true)) { + return (k, true); + } + } + if let Some(fits) = drawn { + let earlier: Vec = points.iter().skip(MAX_DRAWN_CHECKS).rev().copied().collect(); + let fitting = earlier.partition_point(|&k| fits(k, true)); + if let Some(&k) = fitting.checked_sub(1).and_then(|n| earlier.get(n)) { + return (k, true); } } } // 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, + match last_break { + Some(b) if b.0 > i => settle(tabbed, b, &breaks), _ => (j, false), - }; - (end, hy) + } } /// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 9e2362d9d1..b5291488b6 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,7 +1,7 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ - Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, - roman_share, set_width, squeeze_em, tab_advance, word_width, + Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, drawn_after_tab, drawn_width, hangs, leader_outline, optical_hangs, optical_hangs_rtl, + optical_punct, optical_share, roman_share, squeeze_em, tab_advance, word_width, }; use super::region::Region; use super::vertical::{ @@ -342,6 +342,8 @@ fn prepare_paragraph<'a, 'text>( struct PlacementContext<'a> { para: &'a ParaStyle, pr: &'a Range, + /// [`ParagraphData::bidi`]. + bidi: Option<&'a BidiInfo<'a>>, sg: &'a mut [SGlyph], /// [`ParagraphData::line_end`]. line_end: &'a [f64], @@ -376,6 +378,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let PlacementContext { para, pr, + bidi, sg, line_end, continued, @@ -427,11 +430,32 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { + let start = *i; + let glyphs: &[SGlyph] = sg; + // A horizontal line with right-to-left text is drawn in another order than its + // text's, and its tabs take their stops in the order they are drawn: whether the line + // ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]). + let drawn_fits = |end: usize, hyphenated: bool| { + let mut trimmed = end; + while trimmed > start && glyphs.get(trimmed - 1).is_some_and(|g| g.is_space() || g.ch == '\u{2028}') { + trimmed -= 1; + } + let bytes = glyphs.get(start).map_or(pr.start, |g| g.byte)..glyphs.get(end).map_or(pr.end, |g| g.byte); + let drawn: Vec = visual_order(*bidi, pr.start, bytes, glyphs.get(start..end).unwrap_or_default()) + .into_iter() + .map(|offset| start + offset) + .filter(|&j| j < trimmed) + .collect(); + let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 }; + let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0); + drawn_width(glyphs, &drawn, ¶.tabs, ind_l) + hyphen - credit <= width + EPS + }; + let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits); let line = |ty| { break_line( cx.text, - sg, - *i, + glyphs, + start, width, ¶.tabs, ind_l, @@ -442,6 +466,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC *hanging, cx.vertical, line_end, + fits, ) }; let (end, hyph) = line(para.kinsoku_type); @@ -498,6 +523,14 @@ struct LineGeometry { last_of_para: bool, } +impl LineGeometry { + /// The line's glyphs before its trailing spaces (indices into the paragraph's glyphs), in the + /// order they are drawn: the glyphs its tabs are measured over. + fn measured(&self) -> Vec { + self.order.iter().map(|&offset| self.index + offset).filter(|&j| j < self.trimmed).collect() + } +} + struct LineJustification { per_space: f64, per_gap: f64, @@ -538,6 +571,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut let mut placement = PlacementContext { para: paragraph.para, pr: ¶graph.pr, + bidi: paragraph.bidi.as_ref(), sg: &mut paragraph.sg, line_end: ¶graph.line_end, continued: paragraph.continued, @@ -609,10 +643,13 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData .checked_sub(1) .filter(|&k| k >= index && rules.trims_line_end()) .map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, rules, k)); - let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]); + // The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order, + // each tab up to its stop from where the glyphs drawn before it end. + let measured: Vec = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect(); + let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; // The glyphs before `stop` in visual order, each with the advance it is set with. A list // marker does not hang: it is counted with no width. let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { @@ -847,15 +884,16 @@ fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool { // The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from // the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs -// drawn before it end, then the line's hyphen. +// drawn before it end, aligning the glyphs drawn after it, then the line's hyphen. fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 { let sg = ¶graph.sg; let start = line.ax0 - line.opt_l; let mut x = start; - for &j in &line.drawn { + let measured = line.measured(); + for (k, &j) in measured.iter().enumerate().filter(|&(_, &j)| j < line.body) { let Some(g) = sg.get(j) else { continue }; x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) { - tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0 + tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, drawn_after_tab(sg, &measured, k)).0 } else if j + 1 == line.trimmed { g.adv - line.end_trim } else { @@ -1027,8 +1065,9 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed } } -/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop -/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of +/// The advance and leader of tab `glyph_index` of `line`, drawn at `order_index` of +/// [`LineGeometry::order`], at pen position `x`: up to its stop ([`tab_advance`], measured from +/// `tab_origin` less `tab_shift`, aligning the glyphs drawn after it), or, for the last tab of /// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line /// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it /// (`advance`). @@ -1036,7 +1075,7 @@ fn tab_glyph<'p>( paragraph: &'p ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification, - glyph_index: usize, + (glyph_index, order_index): (usize, usize), x: f64, (tab_origin, tab_shift): (f64, f64), advance: f64, @@ -1046,7 +1085,8 @@ fn tab_glyph<'p>( if !sg.get(glyph_index).is_some_and(|g| g.len > 0) { return (advance, ""); } - let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default()); + let rest = line.order.iter().skip(order_index + 1).map(|&offset| line.index + offset).filter(|&j| j < line.trimmed).filter_map(|j| sg.get(j)); + let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, rest); if justification.last_tab != Some(glyph_index) { return (advance, leader); } @@ -1078,13 +1118,14 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let line_em = sg.get(index..position.end).map_or(0.0, |glyphs| glyphs.iter().map(glyph_em).fold(0.0, f64::max)); // Tab stops are measured from the frame's left edge (point type: the origin). let tab_origin = if paragraph.regions.is_some() { position.x0 } else { 0.0 }; - // A line with tabs is measured as if it started at the left (see `set_width`), then + // A line with tabs is measured as if it started at the left (see `drawn_width`), then // aligned as a whole: its tabs stop where they were measured, however it is aligned. let tab_shift = start_x - if paragraph.regions.is_some() { ax0 } else { position.ind_l }; // RTL's logical trailing spaces precede the visible content. Keep them outside - // the aligned content extent, just as LTR trailing spaces extend to its right. - let leading_space_width: f64 = - order.iter().take_while(|&&offset| index + offset >= trimmed).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum(); + // the aligned content extent, just as LTR trailing spaces extend to its right. A tab among + // them keeps its own width. + let leading = order.iter().take_while(|&&offset| index + offset >= trimmed).count(); + let leading_space_width: f64 = order.iter().take(leading).filter_map(|&offset| sg.get(index + offset)).map(|glyph| glyph.adv).sum(); let mut x = start_x - leading_space_width; let mut x_end = start_x; // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. @@ -1100,8 +1141,8 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData // Glyph scaling a justified line adds to the glyph (1: none). let mut scale = 1.0; let mut leader = ""; - if glyph.ch == '\t' { - (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); + if glyph.ch == '\t' && order_index >= leading { + (advance, leader) = tab_glyph(paragraph, line, justification, (glyph_index, order_index), x, (tab_origin, tab_shift), advance); } else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 9d54e6a1cc..94c14c0b51 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -97,6 +97,7 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() { false, false, &[], + None, ); assert_eq!((end, hyphen), (4, true), "{tag}"); let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 }; @@ -169,7 +170,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { 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, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -183,12 +184,26 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { 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, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], 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, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]); + let (second_end, hyphenated) = break_line( + text, + &glyphs, + first_end, + 3.0, + &[], + 0.0, + None, + Burasagari::None, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + None, + ); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -244,7 +259,7 @@ fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); let (end, hyphen) = - break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -253,7 +268,7 @@ fn no_break_keeps_marked_characters_on_one_line() { g.no_break = true; } let (end, hyphen) = - break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -263,10 +278,10 @@ fn no_break_keeps_marked_characters_on_one_line() { glyphs[2].no_break = true; glyphs[3].no_break = true; let (end, _) = - break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None); assert_eq!(end, 2, "the marked pair moves to the next line together"); let (end, _) = - break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]); + break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], None); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); @@ -336,7 +351,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[], None).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -402,7 +417,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); let mixed_end = - |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; + |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); @@ -414,7 +429,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { g.sy *= 2.0; } let tall_end = - |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0; + |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], None).0; assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to @@ -424,7 +439,7 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() { let half = glyphs[1].clone(); glyphs.insert(2, half); let split_end = - |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0; + |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[], None).0; assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } @@ -438,12 +453,198 @@ fn kinsoku_shori_type_counts_no_gap_at_a_format_control() { let mut glyphs = full_width(text); glyphs[2].adv = 0.0; let end = |width: f64| { - break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0 + break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None).0 }; assert_eq!(end(54.0), 5, "五 is pushed out with 」"); assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } +/// A line with a tab that does not fit as drawn ends at the latest break that does, even when an +/// earlier break does not fit. +#[test] +fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() { + let text = "a\tb c d e"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + // Breaks after the spaces end the line before glyphs 4, 6 and 8; the paragraph ends at 9. + let drawn_fits = |end: usize, _hyphenated: bool| end == 4 || end == 8; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (8, false)); +} + +/// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen +/// that fits as drawn, whatever the breaks with a hyphen between them measure. +#[test] +fn a_long_tabbed_line_ends_at_the_latest_plain_break_that_fits_as_drawn() { + // a (0), tab (1), then b, a soft hyphen, b and a space for each u: breaks before 4 + 4u (with a + // hyphen) and 6 + 4u. + let text = format!("a\t{}", "b\u{AD}b ".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let drawn_fits = |end: usize, hyphenated: bool| !hyphenated && end <= 606; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 1e6, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (606, false)); +} + +/// A line with a tab that overflows in the order of its text at many clusters stays whole when it +/// fits as drawn. +#[test] +fn a_tabbed_line_that_fits_as_drawn_stays_whole_past_many_clusters() { + use vectorcraft_doc::{TabAlign, TabStop}; + let text = format!("a\t{}", "b ".repeat(600)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 0.1, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + // In the text's order the line is 500 pt and 120 pt of text wide; drawn, it fits. + let drawn_fits = |_end: usize, _hyphenated: bool| true; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &tabs, + 0.0, + None, + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (glyphs.len(), false)); +} + +/// A line of tabs and glyphs without width stops reading once its tabs reach past the frame: the +/// rest of the paragraph is not measured. +#[test] +fn a_tabbed_line_stops_reading_where_its_tabs_reach_past_the_frame() { + let text = format!("a{}", "\tx ".repeat(2000)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'a' { 1.0 } else { 0.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let furthest = std::cell::Cell::new(0); + let drawn_fits = |end: usize, _hyphenated: bool| { + furthest.set(furthest.get().max(end)); + end <= 4 + }; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(&text, &glyphs, 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (4, false)); + assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get()); +} + +/// A tab to an explicit stop past the frame stops a line from reading on: the rest of the paragraph +/// is not measured. +#[test] +fn a_tab_to_a_stop_past_the_frame_stops_the_line_reading() { + use vectorcraft_doc::{TabAlign, TabStop}; + let text = format!("a b\t{}", "x ".repeat(2000)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), if ch == 'x' { 0.0 } else { 1.0 }, ch)).collect(); + let glyphs = synthetic_glyphs(&specs); + let tabs = [TabStop { position: 500.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let furthest = std::cell::Cell::new(0); + let drawn_fits = |end: usize, _hyphenated: bool| { + furthest.set(furthest.get().max(end)); + end <= 2 + }; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 100.0, + &tabs, + 0.0, + None, + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (2, false)); + assert!(furthest.get() < 100, "measured up to glyph {}", furthest.get()); +} + +/// A tabbed line that ends in a word with more hyphenation points than are measured one by one ends +/// at the latest point that fits as drawn. +#[test] +fn a_tabbed_line_ends_at_the_latest_hyphenation_point_that_fits_as_drawn() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + // a (0), tab (1), then a word of 400 syllables of two glyphs from glyph 2: points before 4, 6, ... + let text = format!("a\t{}", "कि".repeat(400)); + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for glyph in &mut glyphs { + glyph.hyphen_language = crate::hyphen::Language::from_tag(Some("hi")); + glyph.sx = 0.001; + } + let drawn_fits = |end: usize, _hyphenated: bool| end <= 50; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = break_line( + &text, + &glyphs, + 0, + 600.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (50, true)); +} + +/// A glyph with a negative advance after a tab draws the line short of the tab's stop: a line that +/// fits as drawn stays whole. +#[test] +fn a_negative_advance_after_a_tab_keeps_a_fitting_line_whole() { + let text = "a\tb"; + let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, -20.0, 'b')]); + let drawn_fits = |_end: usize, _hyphenated: bool| true; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&drawn_fits); + let ended = + break_line(text, &glyphs, 0, 30.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[], fits); + assert_eq!(ended, (3, false)); + // Two right tabs can align on the same stop when the text after the second steps back: the + // second tab does not reach the next stop. + use vectorcraft_doc::{TabAlign, TabStop}; + let text = "a\tb\tc"; + let glyphs = synthetic_glyphs(&[(0, 1, 1.0, 'a'), (1, 1, 1.0, '\t'), (2, 1, 1.0, 'b'), (3, 1, 1.0, '\t'), (4, 1, -10.0, 'c')]); + let tabs = [100.0, 500.0].map(|position| TabStop { position, align: TabAlign::Right, leader: String::new(), align_on: '.' }); + let ended = break_line( + text, + &glyphs, + 0, + 200.0, + &tabs, + 0.0, + None, + Burasagari::None, + Kinsoku::None, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + fits, + ); + assert_eq!(ended, (5, false)); +} + /// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed /// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last /// glyph of the last cluster counts, and only where the line may end. @@ -455,7 +656,7 @@ fn greedy_wrap_counts_the_narrower_line_end() { let mut credit = vec![0.0; glyphs.len()]; credit[3] = 5.0; let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| { - break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).0 }; assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits"); assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it"); @@ -471,7 +672,7 @@ fn greedy_wrap_counts_the_narrower_line_end() { let comma = "一二三、"; let glyphs = full_width(comma); let end = |line_end: &[f64]| { - break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0 + break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end, None).0 }; assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2); @@ -480,7 +681,7 @@ fn greedy_wrap_counts_the_narrower_line_end() { let pushed = "一二三四」。六"; let glyphs = full_width(pushed); let end = |ty: KinsokuShoriType, line_end: &[f64]| { - break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0 + break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end, None).0 }; let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0]; assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in"); @@ -490,7 +691,7 @@ fn greedy_wrap_counts_the_narrower_line_end() { let pushed = format!("{}二」「四", "一".repeat(10)); let glyphs = full_width(&pushed); let end = |line_end: &[f64]| { - break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0 + break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end, None).0 }; let mut bracket = vec![0.0; glyphs.len()]; bracket[11] = 5.0; @@ -503,8 +704,9 @@ fn greedy_wrap_counts_the_narrower_line_end() { glyphs[2].adv = 5.0; let half = glyphs[2].clone(); glyphs.insert(3, half); - let end = - |line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0; + let end = |line_end: &[f64]| { + break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end, None).0 + }; assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line"); assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line"); } @@ -784,8 +986,9 @@ fn korean(text: &str, anywhere: bool) -> Vec { fn korean_words_break_at_spaces_unless_line_break_anywhere() { use KinsokuShoriType::PushOutOnly; let text = "가나다 라마바"; - let greedy = - |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let greedy = |g: &[SGlyph], width: f64| { + break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0 + }; let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); // 50 pt holds "가나다 라" and not "가나다 라마". assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space"); @@ -804,7 +1007,7 @@ fn korean_words_break_at_spaces_unless_line_break_anywhere() { // A soft return still ends the line. let soft = "가나\u{2028}다라"; assert_eq!( - break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0, + break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0, 3 ); } @@ -825,7 +1028,8 @@ fn korean_words_keep_their_letters_and_break_after_punctuation() { for g in &mut glyphs[1..3] { g.no_break = true; } - let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0; + let end = + break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None).0; assert_eq!(end, 1, "No Break keeps 나다 together"); } @@ -852,6 +1056,7 @@ fn korean_words_take_no_hyphen() { false, false, &[], + None, ); assert_eq!(line, (2, false)); assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0)); @@ -897,8 +1102,9 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() { let marked = |text: &str| pairs(text, true).iter().filter(|g| g.keeps_next).count(); assert_eq!((marked("……‥‥〳〵〴〵"), marked("—\u{2028}—"), marked("—〵"), marked("〵〳")), (4, 0, 0, 0)); // 35 pt holds "あい—" and not "あい——". - let greedy = - |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let greedy = |g: &[SGlyph], width: f64| { + break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[], None).0 + }; assert_eq!(greedy(&pairs(text, false), 35.0), 3, "off: the line ends between the dashes"); assert_eq!(greedy(&pairs(text, true), 35.0), 2, "on: both dashes go to the next line"); assert_eq!(breaking::word_width(&pairs("——あ", true), 0, &[], Kinsoku::Hard), 20.0); @@ -916,7 +1122,7 @@ fn bunri_kinshi_keeps_jlreq_pairs_in_both_composers() { fn bunri_kinshi_progresses_with_an_overlong_pair_and_no_break() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let line = |text: &str, g: &[SGlyph], i: usize, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| { - break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[]).0 + break_line(text, g, i, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, false, false, &[], None).0 }; let text = "——あ"; assert_eq!(line(text, &pairs(text, true), 0, 15.0, Kinsoku::Hard, PushOutOnly), 2, "the pair overflows the line whole"); diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index 8212a652e3..bc1e5b1b13 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -540,6 +540,134 @@ fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() { assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1); } +/// Right-to-left area type `text` in a frame `width` wide, aligned as `justify`, with tab stops at +/// `stops` (position and alignment). +fn rtl_tabbed(text: &str, width: f64, stops: &[(f64, TabAlign)], justify: Justify) -> TextLayout { + let mut t = area(text, width, justify, no_ranges()); + t.para.tabs = stops.iter().map(|&(position, align)| TabStop { position, align, leader: String::new(), align_on: '.' }).collect(); + layout(db(), &t) +} + +/// Where the glyphs of line `k` that are not spaces start and end, as drawn. +fn drawn_extent(l: &TextLayout, text: &str, k: usize) -> (f64, f64) { + let ink = l.glyphs.iter().filter(|g| g.line == k && !text[g.byte..].starts_with(' ')); + ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance))) +} + +/// A right-to-left line with a tab is measured as it is drawn: right aligned (and Auto, which +/// aligns it right) it ends on the frame's right edge, and centered it leaves as much room at +/// each side. +#[test] +fn a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn() { + let text = "אב גד\tהו זח"; + for justify in [Justify::Right, Justify::Auto, Justify::Center] { + let l = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Left)], justify); + assert_eq!(l.lines.len(), 1, "{justify:?}"); + let (left, right) = drawn_extent(&l, text, 0); + if justify == Justify::Center { + assert!((left - (300.0 - right)).abs() < 1e-6, "{justify:?}: centered as drawn: {left} {right}"); + } else { + assert!((right - 300.0).abs() < 1e-6, "{justify:?}: ends on the right edge: {right}"); + } + assert!((l.lines[0].x1 - right).abs() < 1e-6, "{justify:?}: the line's record ends where it is drawn: {}", l.lines[0].x1); + } +} + +/// Point type: a right-aligned right-to-left line with a tab ends at the origin as drawn. +#[test] +fn right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin() { + let text = "אב גד\tהו זח"; + let mut t = point(text, no_ranges()); + t.para.justify = Justify::Right; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + let (_, right) = drawn_extent(&l, text, 0); + assert!(right.abs() < 1e-6, "ends at the origin: {right}"); +} + +/// A right-to-left line with a tab breaks where it fits as drawn: in a frame it fits only in the +/// order of its text, it wraps, and every line stays inside the frame. +#[test] +fn a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn() { + // א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17). + let text = "אב גד\tהו זח"; + let wide = rtl_tabbed(text, 1000.0, &[(150.0, TabAlign::Left)], Justify::Left); + let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() }; + // Drawn, the text before the tab comes after it: the line is 150 pt and that text wide. + let (before, after) = (span(0, 9), span(10, 19)); + assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}"); + let width = 150.0 + (before + after) / 2.0; + let l = rtl_tabbed(text, width, &[(150.0, TabAlign::Left)], Justify::Right); + assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len()); + for k in 0..l.lines.len() { + let (left, right) = drawn_extent(&l, text, k); + assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})"); + } +} + +/// Right, center and decimal stops align the text drawn after the tab, which in a right-to-left +/// line is the text before it. +#[test] +fn right_to_left_tab_stops_align_the_text_drawn_after_them() { + // מ (0), מ (2), tab (4), ו (5), ו (7): drawn, וו comes first and ממ after the tab. + let text = "ממ\tוו"; + let mem = |l: &TextLayout| -> (f64, f64) { + l.glyphs + .iter() + .filter(|g| g.byte < 4) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), g| (lo.min(g.origin.x), hi.max(g.origin.x + g.advance))) + }; + let right = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Right)], Justify::Left); + assert!((mem(&right).1 - 150.0).abs() < 1e-6, "right stop: ends on the stop: {:?}", mem(&right)); + let center = rtl_tabbed(text, 300.0, &[(150.0, TabAlign::Center)], Justify::Left); + let (lo, hi) = mem(¢er); + assert!(((lo + hi) / 2.0 - 150.0).abs() < 1e-6, "center stop: centered on the stop: {lo} {hi}"); + // 1 (0), . (1), 5 (2), tab (3), מ (4), מ (6): drawn, ממ comes first and 1.5 after the tab. + let decimal = rtl_tabbed("1.5\tממ", 300.0, &[(150.0, TabAlign::Decimal)], Justify::Left); + assert!((at(&decimal, 1).origin.x - 150.0).abs() < 1e-6, "decimal stop: the point on the stop: {}", at(&decimal, 1).origin.x); +} + +/// Two tabs around a left-to-right run in a right-to-left line: each takes its stop in drawing +/// order, and the right-aligned line ends on the right edge as drawn. +#[test] +fn a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge() { + // מ (0), מ (2), tab (4), A B space C D (5..10), tab (10), ו (11), ו (13). + let text = "ממ\tAB CD\tוו"; + let l = rtl_tabbed(text, 300.0, &[(80.0, TabAlign::Left), (160.0, TabAlign::Left)], Justify::Right); + assert_eq!(l.lines.len(), 1); + let (_, right) = drawn_extent(&l, text, 0); + assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}"); + assert!(at(&l, 5).origin.x < at(&l, 6).origin.x, "the Latin run keeps its order"); +} + +/// A right-to-left line with a tab and more breaks than are measured as drawn stays one line when +/// it fits the frame. +#[test] +fn a_long_right_to_left_line_with_a_tab_that_fits_stays_one_line() { + let text = format!("א\t{}", "ב ".repeat(400)); + let l = rtl_tabbed(&text, 100_000.0, &[(150.0, TabAlign::Left)], Justify::Right); + assert_eq!(l.lines.len(), 1, "one line"); +} + +/// A right-to-left line with a tab and more breaks than are measured one by one still wraps when +/// it fits only in the order of its text, and every line stays inside the frame. +#[test] +fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as_drawn() { + // אב גד, a tab, הו, 400 soft hyphens (each a break), a space and זח. + let text = format!("אב גד\tהו{} זח", "\u{AD}".repeat(400)); + let wide = rtl_tabbed(&text, 10_000.0, &[(150.0, TabAlign::Left)], Justify::Left); + let span = |from: usize, to: usize| -> f64 { wide.glyphs.iter().filter(|g| (from..to).contains(&g.byte)).map(|g| g.advance).sum() }; + let (before, after) = (span(0, 9), span(10, text.len())); + assert!(before > after + 1.0, "the test needs the text before the tab wider: {before} {after}"); + let width = 150.0 + (before + after) / 2.0; + let l = rtl_tabbed(&text, width, &[(150.0, TabAlign::Left)], Justify::Right); + assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len()); + for k in 0..l.lines.len() { + let (left, right) = drawn_extent(&l, &text, k); + assert!(left >= -1e-6 && right <= width + 1e-6, "line {k} stays in the frame: {left} {right} (frame {width})"); + } +} + /// Point type has no measure to fill: a justified line with a tab keeps its natural width. #[test] fn justified_point_type_with_a_tab_keeps_its_width() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..411e80293e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -41,6 +41,7 @@ Window → Help Bar toggles the bottom tool-help strip independently of the stat The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. - File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged +- Type core: a right-to-left line with tabs is measured in the order it is drawn, with tab stops still measured from the left edge. Each tab goes to its stop from where the glyphs drawn before it end, and right, center and decimal stops align the text drawn after the tab. Right, Auto and centered lines are aligned by that width, and area type wraps a line that is too wide when drawn. Evidence: `tests::justify::a_right_to_left_line_with_a_tab_is_aligned_as_it_is_drawn`, `tests::justify::a_right_to_left_line_with_a_tab_wraps_when_it_does_not_fit_as_drawn`, `tests::justify::right_to_left_tab_stops_align_the_text_drawn_after_them`, `tests::justify::a_right_to_left_line_with_two_tabs_and_a_latin_run_ends_on_the_right_edge`, `tests::justify::right_aligned_right_to_left_point_type_with_a_tab_ends_at_its_origin`; scores and estimates unchanged - File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) From 804177bddd7cd178343697764b35825f9c15279d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:41:24 -0800 Subject: [PATCH 15/27] Offset Path: work out the offsets of several selected paths on several threads Offset Path took each selected path in turn, so offsetting many paths, or a group of many paths, used one CPU. It now reads the selected paths first, works out their offsets on as many threads as the computer has logical CPUs (one thread on the web and for a single path), and then makes the new objects one after another in the selection's order on the editing thread. The new objects, their ids, their stacking, the selection, the messages and the undo step are the same as before. When several paths fail, the error is the one for the first of them in the selection's order, and the paths after a failure that have not started yet are skipped. --- crates/engine/src/cmd/pathops.rs | 144 +++++++++++++++++++++++++---- crates/engine/src/tests/pathops.rs | 92 ++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 217 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index b4ce273dad..0085c2d9d6 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -803,35 +803,59 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0); let roots = selected_roots(s)?; let ids = s.edit("Offset Path", |d, sel| { - let (mut out, mut above) = (vec![], vec![]); - let mut paths = false; + // Each selected root, whether it is a group, and the style of each of its leaf paths; the + // paths themselves go to `sources` in the same order. + let (mut roots_found, mut sources) = (vec![], vec![]); for id in &roots { - let Some(n) = d.node(*id).cloned() else { continue }; - let mut lv = vec![]; - leaves(&n, &mut lv); + let Some(n) = d.node(*id) else { continue }; + let mut styles = vec![]; + each_leaf(n, &mut |l| { + if !matches!(l.kind, NodeKind::Text(_)) + && let Some((path, rule)) = node_path(l) + { + styles.push(Style::of(l)); + sources.push((path, rule, matches!(l.kind, NodeKind::Compound { .. }))); + } + }); + roots_found.push((*id, matches!(n.kind, NodeKind::Group { .. }), styles)); + } + let paths = !sources.is_empty(); + let offset = |(path, rule, compound): &(PathData, FillRule, bool)| { + let normalized; + let base = if *compound && path.is_closed() { + normalized = po::normalize(path, *rule); + &normalized + } else { + path + }; + match po::try_offset_path(base, delta, join, miter) { + Ok(p) => Ok(Some(p)), + Err(po::PathOpsError::NonFinite) => Ok(None), + Err(e) => Err(e), + } + }; + // The offsets are worked out together; the nodes are made here, in selection order, so + // the ids, the stacking and the first error are those of one path after another. + let (offsets, _) = ordered_map(&sources, offset_threads(), offset, std::result::Result::is_err); + let mut offsets = offsets.into_iter(); + let (mut out, mut above) = (vec![], vec![]); + for (id, group, styles) in roots_found { let mut made = vec![]; - for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) { - let Some((path, rule)) = node_path(l) else { continue }; - paths = true; - let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path }; - let off = match po::try_offset_path(&base, delta, join, miter) { - Ok(p) => p, - Err(po::PathOpsError::NonFinite) => continue, - Err(po::PathOpsError::TooComplex) => { + for style in &styles { + match offsets.next().flatten() { + Some(Ok(Some(off))) if !off.is_empty() => made.push(shape_node_styled(d, off, style)), + Some(Err(po::PathOpsError::TooComplex)) => { return Err(EngineError::Other("Offset Path: the path is too complex to offset".into())); } - Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))), - }; - if off.is_empty() { - continue; + Some(Err(e)) => return Err(EngineError::Other(format!("Offset Path: {e}"))), + _ => {} } - made.push(shape_node(d, off, Some(l))); } if made.is_empty() { continue; } let new = if made.len() == 1 - && !matches!(n.kind, NodeKind::Group { .. }) + && !group && let Some(one) = made.pop() { one @@ -840,7 +864,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { Node::group(gid, made.into_iter().map(Arc::new).collect()) }; out.push(new.id); - above.push((*id, vec![new])); + above.push((id, vec![new])); } if out.is_empty() && paths { return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into())); @@ -855,6 +879,86 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// The threads [`offset_path`] works on: the logical CPUs, or one on the web. +fn offset_threads() -> usize { + #[cfg(target_arch = "wasm32")] + { + 1 + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::available_parallelism().map_or(1, std::num::NonZero::get) + } +} + +/// `f` of each of `items`, in their order. The items are split into runs whose sizes differ by +/// at most one, as many as `threads` or the items allow and at least one. This thread works +/// on the first run, and a thread of its own works on each other run (this thread does when that +/// one can't start). Once `stop` accepts a result, the items after it are left out (`None`); every +/// item before the first such result has its result. Returns the results and the number of +/// threads that worked on them. +pub(crate) fn ordered_map( + items: &[T], + threads: usize, + f: impl Fn(&T) -> R + Sync, + stop: impl Fn(&R) -> bool + Sync, +) -> (Vec>, usize) { + use std::sync::atomic::{AtomicUsize, Ordering}; + let mut out: Vec> = std::iter::repeat_with(|| None).take(items.len()).collect(); + let stopped = AtomicUsize::new(usize::MAX); + let run = |from: usize, its: &[T], out: &mut [Option]| { + for (i, (item, slot)) in its.iter().zip(out).enumerate() { + let at = from + i; + if at > stopped.load(Ordering::Relaxed) { + return; + } + let r = f(item); + if stop(&r) { + stopped.fetch_min(at, Ordering::Relaxed); + } + *slot = Some(r); + } + }; + let runs = threads.clamp(1, items.len().max(1)); + if runs == 1 { + run(0, items, &mut out); + return (out, 1); + } + // Run `k` holds the items from `start(k)` up to `start(k + 1)`. + let start = |k: usize| k * items.len() / runs; + let mut unstarted = vec![]; + let used = std::thread::scope(|s| { + let mut rest = out.as_mut_slice(); + let mut first = None; + let mut used = 1; + for k in 0..runs { + let (from, to) = (start(k), start(k + 1)); + let Some((o, tail)) = std::mem::take(&mut rest).split_at_mut_checked(to - from) else { break }; + rest = tail; + let its = items.get(from..to).unwrap_or_default(); + if k == 0 { + first = Some((o, its)); + continue; + } + let run = &run; + match std::thread::Builder::new().spawn_scoped(s, move || run(from, its, o)) { + Ok(_) => used += 1, + Err(_) => unstarted.push(k), + } + } + if let Some((o, its)) = first { + run(0, its, o); + } + used + }); + for (from, to) in unstarted.into_iter().map(|k| (start(k), start(k + 1))) { + if let (Some(o), Some(its)) = (out.get_mut(from..to), items.get(from..to)) { + run(from, its, o); + } + } + (out, used) +} + // ---------- Outline Stroke ---------- /// Can Outline Stroke turn `st` into filled art? diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 1397357061..b587740744 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -441,6 +441,98 @@ fn offset_open_path_whose_ends_meet_grows_the_shape() { assert_eq!(path_of(&s, r[0]).0.subpaths.len(), 1); } +/// The ids of the objects in the first layer, back to front. +fn first_layer(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +/// Offset Path of several objects at once: each result goes directly above its object and takes +/// the next ids in the objects' order, a group gives a group of its paths' offsets with their +/// styles, a compound path gives a compound path, an inset that leaves nothing and type give +/// nothing, and one undo gives the objects back. +#[test] +fn offset_path_of_several_objects_keeps_their_order() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let c = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + set_fill(&mut s, b, Color::rgb(1.0, 0.0, 0.0)); + set_fill(&mut s, c, Color::rgb(0.0, 0.0, 1.0)); + s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let tiny = rect(&mut s, 100.0, 0.0, 4.0, 4.0); + let outer = rect(&mut s, 200.0, 0.0, 40.0, 40.0); + let inner = rect(&mut s, 210.0, 10.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap(); + let cp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 10, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let before = first_layer(&s); + assert_eq!(before, [a, g, tiny, cp, t]); + let first = s.doc().unwrap().doc.peek_next_id(); + s.execute("select.set", &json!({"ids": [a.0, g.0, tiny.0, cp.0, t.0]})).unwrap(); + let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": -3, "joins": "miter"})).unwrap()); + // a's offset, then b's and c's and their group, then the compound path and its two subpaths. + assert_eq!(r, [NodeId(first), NodeId(first + 3), NodeId(first + 4)]); + assert_eq!(first_layer(&s), [a, r[0], g, r[1], tiny, cp, r[2], t]); + assert_eq!(s.doc().unwrap().selection.objects, r); + assert!(close(bounds(&s, r[0]), Rect::new(3.0, 3.0, 17.0, 17.0), 1e-2), "{:?}", bounds(&s, r[0])); + let group = node(&s, r[1]); + assert!(matches!(group.kind, NodeKind::Group { .. }), "{:?}", group.kind); + let kids = group.children().unwrap(); + assert_eq!(kids.iter().map(|k| k.id).collect::>(), [NodeId(first + 1), NodeId(first + 2)]); + assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(kids[1].appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + let rings = node(&s, r[2]); + assert!(matches!(rings.kind, NodeKind::Compound { .. }), "{:?}", rings.kind); + assert!(close(bounds(&s, r[2]), Rect::new(203.0, 3.0, 237.0, 37.0), 1e-2), "{:?}", bounds(&s, r[2])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(first_layer(&s), before); +} + +/// Offset Path works out the offsets of several paths on more than one thread, and gives the +/// results in the paths' order. +#[test] +fn offset_path_works_on_several_threads_in_order() { + let seen = std::sync::Mutex::new(vec![]); + let items: Vec = (0..8).collect(); + let work = |i: &usize| { + if let Ok(mut seen) = seen.lock() { + seen.push(std::thread::current().id()); + } + i * 2 + }; + let (out, used) = crate::cmd::pathops::ordered_map(&items, 4, work, |_| false); + assert_eq!(out, (0..8).map(|i| Some(i * 2)).collect::>()); + assert_eq!(used, 4); + let threads: std::collections::HashSet<_> = seen.into_inner().unwrap().into_iter().collect(); + assert_eq!(threads.len(), 4); + // A count just past the threads still uses every thread. + let (out, used) = crate::cmd::pathops::ordered_map(&items[..5], 4, |i| i * 2, |_| false); + assert_eq!(out, (0..5).map(|i| Some(i * 2)).collect::>()); + assert_eq!(used, 4); + // One item, or one thread, is worked on here. + let (out, used) = crate::cmd::pathops::ordered_map(&items[..1], 4, |i| i * 2, |_| false); + assert_eq!((out, used), (vec![Some(0)], 1)); + let (out, used) = crate::cmd::pathops::ordered_map(&items, 1, |i| i * 2, |_| false); + assert_eq!(used, 1); + assert_eq!(out.len(), 8); +} + +/// Once a result stops the work (an offset that fails), the paths after it are left out, and every +/// path before it has its result, so the first failure in the paths' order is the one reported. +#[test] +fn offset_path_stops_after_the_first_failure() { + let items: Vec = (0..8).collect(); + let (out, _) = crate::cmd::pathops::ordered_map(&items, 1, |i| *i, |r| *r == 2 || *r == 5); + assert_eq!(out, [Some(0), Some(1), Some(2), None, None, None, None, None]); + for threads in [2, 4, 8] { + let (out, _) = crate::cmd::pathops::ordered_map(&items, threads, |i| *i, |r| *r == 2 || *r == 5); + assert_eq!(out[..3], [Some(0), Some(1), Some(2)], "{threads} threads"); + let first = out.iter().position(|r| r.is_some_and(|r| r == 2 || r == 5)); + assert_eq!(first, Some(2), "{threads} threads"); + } +} + #[test] fn outline_stroke_of_line() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..f00550dff4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1749,6 +1749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Pathfinder › Merge and the `pathfinder.merge` group effect find the faces of every fill in a few contour passes over the arrangement, as Trim and Crop do, instead of one pass for each distinct fill. The output paths and their order are the same. With 8,000 squares that do not touch, each with its own fill, Merge takes 72 to 107 ms instead of 17 to 32 s (release build). The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there. + Offset Path on several selected paths, or on a group of them, works out their offsets on as many threads as the computer has logical CPUs (one thread on the web and for a single path), and then makes the new objects one after another in the selection's order. The new objects, their ids, their stacking, the selection and the undo step are the same as when the paths were offset one at a time, and when more than one path fails, the error is the one for the first of them in that order (`tests::pathops::offset_path_of_several_objects_keeps_their_order`, `tests::pathops::offset_path_works_on_several_threads_in_order`, `tests::pathops::offset_path_stops_after_the_first_failure`). Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running). The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running). Pathfinder › Unite and the other commands that unite filled paths sweep a single input once instead of twice when its own sweep neither splits nor drops any of its segments and no two of its segments lie within twice the sweep tolerance. The output is the same. Unite on one compound path of 4,000 rings takes 485 to 542 ms instead of 872 to 1,008 ms, and on one zigzag polygon of 20,000 teeth 70 to 76 ms instead of 118 to 130 ms (release build, medians of three alternating rounds of five runs, while other builds were running). From 98d5708df46cda037dd28f1e20e30602586c3b3e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:42:39 -0800 Subject: [PATCH 16/27] Artwork Cleanup: Break Text and Remove Breaks read the text threads once Break Text checked every thread of the document for each selected type object to skip threaded frames, and Remove Breaks searched every thread for each selected object to find its story, so the work grew with the number of selected objects times the number of threaded frames. Both commands now index the document's threads once per command: Break Text tests membership in that index, and Remove Breaks takes the story from the indexed thread through threads::story_in, the second half of story_of. Split Text at Cursor, which takes one object, checks it once as before. The results are unchanged. Tests: Break Text on two threaded frames and six point type objects skips the frames unchanged, breaks the rest in place as one undo step and goes through the threads once; Remove Breaks reads the selected story as one text and the other objects on their own, going through the threads once; Split Text at Cursor refuses a threaded frame. --- crates/engine/src/cmd/addons/cleanup/text.rs | 41 ++++++++-- .../src/cmd/addons/cleanup/text/tests.rs | 79 +++++++++++++++++++ crates/engine/src/cmd/threads.rs | 5 ++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 7d7dcbb197..052ba901ed 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -206,11 +206,31 @@ struct Source { text: String, } +#[cfg(test)] +thread_local! { + /// Tests: how many times [`thread_index`] went through the document's threads on this thread. + static THREAD_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Each object the document's text threads list, with the index of the first thread that lists it. +fn thread_index(d: &Document) -> std::collections::HashMap { + #[cfg(test)] + THREAD_SCANS.with(|c| c.set(c.get() + 1)); + let mut out = std::collections::HashMap::new(); + for (i, thread) in d.text_threads.iter().enumerate() { + for id in thread { + out.entry(*id).or_insert(i); + } + } + out +} + impl Source { - /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. - fn of(d: &Document, id: NodeId) -> Option { + /// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all + /// fits; None otherwise. + fn of(d: &Document, id: NodeId, threaded: bool) -> Option { let t = text_of(d, id)?; - if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded { return None; } let lay = layout(t); @@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result { }; let d = &s.doc()?.doc; let (mut items, mut skipped, mut count) = (vec![], vec![], 0); - for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; + let threads = thread_index(d); + for id in ids { + let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else { skipped.push(id); continue; }; @@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result { return Err(EngineError::Other("Click in type with a Type tool to split it".into())); }; let d = &s.doc()?.doc; - let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + let threaded = d.text_threads.iter().any(|th| th.contains(&id)); + let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else { return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); }; let text = &src.text; @@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?; // A threaded story whose frames are all among them is read as one text, once. let named: std::collections::HashSet = ids.iter().copied().collect(); + let threads = thread_index(d); let mut done = std::collections::HashSet::new(); let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![]; for &id in &ids { if !done.insert(id) { continue; } - let story = crate::cmd::threads::story_of(d, id); + let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) { + Some(thread) => crate::cmd::threads::story_in(d, thread, id), + None => vec![], + }; if !story.is_empty() && story.iter().all(|f| named.contains(f)) { done.extend(story.iter().copied()); out.extend(story_cuts(d, &story, brk)); diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 3769cf3b42..cfa78a5ce5 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() { assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); } +/// Two area frames threaded together, with `story` flowing through them → (first, second). +fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) { + let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}})); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap()); + select(s, &[a, b]); + s.execute("text.thread.create", &json!({})).unwrap(); + (a, b) +} + +fn thread_scans() -> usize { + super::THREAD_SCANS.with(|c| c.get()) +} + +/// Threaded frames in the selection stay as they are and are reported as skipped; the other type +/// is broken. The document's threads are gone through once for the whole selection, not once +/// per object. +#[test] +fn break_text_skips_threaded_frames_and_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "Threaded story text"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14}))); + } + let was = glyphs(&s, &plain); + let frames = (obj(&s, a), obj(&s, b)); + let before = doc_json(&s); + let steps = undo_steps(&s); + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]); + assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are"); + let pieces = ids(&r); + assert_eq!(pieces.len(), 12); + assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected"); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert_eq!(undo_steps(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn split_text_refuses_a_threaded_frame() { + let mut s = session(); + let (a, _) = threaded(&mut s, "Threaded story text"); + select(&mut s, &[a]); + edit_text(&mut s, a, 9, 9); + let before = doc_json(&s); + assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type")); + assert_eq!(doc_json(&s), before); +} + #[test] fn combine_puts_glyph_pieces_back_together_where_they_were() { let mut s = session(); @@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other +/// object on its own, going through the document's threads once for the whole selection. +#[test] +fn remove_breaks_reads_the_threads_once() { + let mut s = session(); + let (a, b) = threaded(&mut s, "one\ntwo"); + let mut plain = vec![]; + for i in 0..6 { + plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12}))); + } + let mut all = vec![a, b]; + all.extend(&plain); + select(&mut s, &all); + let scans = thread_scans(); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(thread_scans() - scans, 1, "the threads are gone through once"); + assert_eq!(r["removed"], 7); + assert_eq!(obj(&s, a).plain_text(), "one two"); + for id in plain { + assert_eq!(obj(&s, id).plain_text(), "a b"); + } +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index c1c3801e40..1a802a286e 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -365,6 +365,11 @@ pub(crate) fn mark_continued(doc: &mut Document) { /// path; empty when `id` is in no thread or the thread has fewer than two such frames. pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; + story_in(d, thread, id) +} + +/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it. +pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec { let frames: Vec = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect(); if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] } } From 4dc5c4f719343d0714ceadcc967e3410252bb687 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:46:06 -0800 Subject: [PATCH 17/27] Live Shapes: object.setLiveShape refuses unknown keys and invalid sides or radius object.setLiveShape was in params::UNCHECKED, so a key it does not read, such as `path`, ran the command with the key ignored. Its description names every key it reads, so it now goes through check_keys like other commands, for MCP, scripts and command.batch steps. Each of `items` takes `id` and `corners` alone; another key is refused as `items[0].corner`, with the nearest accepted key. Saved steps still replay leniently. A `sides` that is not a whole number from 3 to 1000 (2.5, -1, 2, 1001) and a negative `radius` are now errors naming the key, and leave the document and its undo history as they were; a radius of 0 still removes rounding. The Transform panel's Sides field takes 3 to 1000, the Corners dialog's radius field stops at 0, and the panels' corner radius fields send 0 for a negative value typed in. --- crates/engine/src/cmd/object/shape.rs | 5 ++-- crates/engine/src/cmd/params.rs | 41 ++++++++++++++++++++++++-- crates/engine/src/tests/livecorners.rs | 37 +++++++++++++++++++++++ crates/engine/src/tests/params.rs | 37 +++++++++++++++++++++++ crates/ui-egui/src/dialogs/corners.rs | 2 +- crates/ui-egui/src/panels.rs | 5 ++-- crates/ui-egui/src/panels/transform.rs | 2 +- docs/mcp.md | 5 ++++ docs/target-app-parity.md | 1 + 9 files changed, 127 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index 2b32c59980..e3909c5935 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -135,9 +135,7 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { .map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) }) .transpose()?; - let radius = p.get("radius").and_then(Value::as_f64); let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); - let sides = p.get("sides").and_then(Value::as_u64); // A number that must be `what` (None when absent). let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { match p.get(k) { @@ -151,6 +149,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); + let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?; + // A polygon's side count: a whole number from 3 to 1000. + let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64); // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index dd59db4d81..20e5054aeb 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -8,7 +8,9 @@ //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't //! described key by key, so only the top level is checked, except in the options of an export, a //! save or Export for Screens: those are checked against the options of the formats the command -//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]). +//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the +//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of +//! `object.setLiveShape`. //! //! [`Session::execute`] answers a key the description doesn't name with a parameter error //! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the @@ -269,6 +271,41 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError ) } +/// Commands whose params hold an array of objects with keys of their own: the command, the key of +/// the array and the keys each of its objects takes. +const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])]; + +/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or +/// the parameter error naming the first other one with its place (`items[0].corner`), and the +/// nearest accepted key or the accepted ones. A member that isn't an object is the command's to +/// report. +fn check_member_keys(id: &str, params: &Value) -> Result<()> { + for (_, list, keys) in MEMBER_KEYS.iter().filter(|(c, ..)| *c == id) { + let Some(members) = params.get(*list).and_then(Value::as_array) else { continue }; + for (i, member) in members.iter().enumerate() { + let Value::Object(given) = member else { continue }; + let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { continue }; + let at = |k: &str| format!("{list}[{i}].{k}"); + let near = nearest(key, keys); + let key = at(&shown_key(key)); + return Err(bad( + id, + match near { + Some(near) => { + let near = at(near); + format!("unknown parameter `{key}`; did you mean `{near}`?") + } + None => { + let keys = keys.iter().map(|k| at(k)).collect::>().join(", "); + format!("unknown parameter `{key}`; the command takes: {keys}") + } + }, + )); + } + } + Ok(()) +} + /// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the /// effect takes (those of its dialog defaults and of its params description), each of the type /// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`]) @@ -526,7 +563,6 @@ pub const UNCHECKED: &[&str] = &[ "object.rasterize", "object.repeat.options", "object.setBounds", - "object.setLiveShape", "object.setOverprint", "object.setProps", "object.slice.divide", @@ -833,6 +869,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> { return Ok(()); } check_keys(id, spec.params, params)?; + check_member_keys(id, params)?; if in_batch { crate::schemas::check(spec, params, false)?; } diff --git a/crates/engine/src/tests/livecorners.rs b/crates/engine/src/tests/livecorners.rs index 888ce24dbd..190cd0d354 100644 --- a/crates/engine/src/tests/livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -89,6 +89,43 @@ fn bad_corners_and_kinds_are_errors() { assert_eq!(corners(&s, id), ([0.0; 4], [CornerKind::Round; 4], 4), "nothing changed"); } +/// A side count that isn't a whole number from 3 to 1000, a negative radius and an unknown key +/// (at the top level or in one of `items`) are errors naming the key; the shapes and the undo +/// history stay as they were. A radius of 0 and the ends of the side range still work. +#[test] +fn bad_sides_radii_and_keys_are_errors() { + let (mut s, id) = session(); + let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap()); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (corners(s, id), st.doc.node(poly).unwrap().clone(), st.history.undo.len()) + }; + let before = state(&s); + for (p, key) in [ + (json!({"ids": [poly.0], "sides": 2.5}), "`sides` must be a whole number from 3 to 1000, not 2.5"), + (json!({"ids": [poly.0], "sides": -1}), "`sides` must be a whole number from 3 to 1000, not -1"), + (json!({"ids": [poly.0], "sides": 2}), "`sides` must be a whole number from 3 to 1000, not 2"), + (json!({"ids": [poly.0], "sides": 1001}), "`sides` must be a whole number from 3 to 1000, not 1001"), + (json!({"ids": [id.0, poly.0], "radius": -4}), "`radius` must be a radius of 0 or more, not -4"), + (json!({"items": [{"id": id.0, "corners": [0]}], "radius": -0.5}), "`radius` must be a radius of 0 or more, not -0.5"), + (json!({"id": id.0, "path": [[0, 0]], "radius": 5}), "unknown parameter `path`"), + (json!({"items": [{"id": id.0, "corner": [0]}], "radius": 5}), "unknown parameter `items[0].corner`; did you mean `items[0].corners`?"), + ] { + match s.execute("object.setLiveShape", &p) { + Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "object.setLiveShape" && msg.starts_with(key), "{p}: {msg}"), + other => panic!("{p}: {other:?}"), + } + assert_eq!(state(&s), before, "{p}"); + } + run(&mut s, json!({"ids": [poly.0], "sides": 3})); + assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 3, .. }))); + run(&mut s, json!({"ids": [poly.0], "sides": 1000.0})); + assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 1000, .. }))); + run(&mut s, json!({"id": id.0, "radius": 6})); + run(&mut s, json!({"id": id.0, "radius": 0})); + assert_eq!(corners(&s, id).0, [0.0; 4]); +} + #[test] fn direct_selected_corners_round_alone_and_stay_selected() { let (mut s, id) = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index e72f607030..6fb8d30467 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,43 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// `object.setLiveShape` names every key it reads, and each of its `items` takes `id` and +/// `corners` alone: another key, at the top level, in an item or in a batch step, is refused +/// instead of being ignored. +#[test] +fn set_live_shape_refuses_an_unknown_key() { + const ID: &str = "object.setLiveShape"; + assert!(!UNCHECKED.contains(&ID)); + let e = refusal(ID, json!({"id": 3, "path": [[0, 0], [1, 1]], "radius": 5})); + assert!(e.starts_with("unknown parameter `path`; the command takes: ") && e.contains("relativeRadii"), "{e}"); + let e = refusal(ID, json!({"radiuss": 5})); + assert_eq!(e, "unknown parameter `radiuss`; did you mean `radius`?"); + let e = refusal(ID, json!({"items": [{"id": 3, "corners": [0]}, {"id": 4, "corner": [0]}], "radius": 5})); + assert_eq!(e, "unknown parameter `items[1].corner`; did you mean `items[1].corners`?"); + let e = refusal(ID, json!({"items": [{"id": 3, "radius": 5}]})); + assert_eq!(e, "unknown parameter `items[0].radius`; the command takes: items[0].id, items[0].corners"); + let step = |params| json!({"commands": [{"command": "select.all", "params": {}}, {"command": ID, "params": params}]}); + for (bad, key) in [(json!({"path": "x"}), "path"), (json!({"items": [{"id": 3, "kind": "chamfer"}]}), "items[0].kind")] { + match check_params("command.batch", &step(bad.clone())) { + Err(EngineError::BadParams { cmd, msg }) => { + assert!(cmd == ID && msg.starts_with(&format!("unknown parameter `{key}`")), "{bad}: {cmd} {msg}") + } + other => panic!("{bad}: {other:?}"), + } + } + for ok in [ + json!({"items": [{"id": 3, "corners": [0, 2]}, {"id": 4}], "radius": 5}), + json!({"corners": [0], "kind": "chamfer", "radius": 2, "relativeRadii": false}), + json!({"id": 3, "radius": 5}), + json!({"ids": [3], "sides": 6, "polygonAngle": 15, "polygonRadius": 20, "sideLength": null, "makeSidesEqual": true}), + json!({"pieStart": 30, "pieEnd": 90, "invertPie": true}), + json!({"items": null}), + ] { + assert!(check_params(ID, &ok).is_ok(), "{ok}"); + assert!(check_params("command.batch", &step(ok.clone())).is_ok(), "{ok}"); + } +} + /// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, /// naming its key, not as a missing id; an id left out is still a missing one. #[test] diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 939acd9c9e..e5300fe5e4 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -105,7 +105,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("radius".into(), json!(n)); } } else { - form::length(ui, d, "radius", unit, 120.0); + form::length_within(ui, d, "radius", unit, 120.0, Some(0.0..=f64::INFINITY)); } ui.end_row(); }); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 62538d8f2f..71019385b6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -123,7 +123,8 @@ fn corner_fields(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str, widt for (k, label) in rectangle_corners() { dim_label(ui, label); if let Some(v) = crate::widgets::num_field_named(ui, (id, "corner", k), Some(label), Some(lc.radius(k)), unit, width) { - app.run("object.setLiveShape", json!({"radius": v, "corners": [k]})).ok(); + // A negative radius typed in rounds nothing: the command takes 0 or more. + app.run("object.setLiveShape", json!({"radius": v.max(0.0), "corners": [k]})).ok(); } ui.end_row(); } @@ -133,7 +134,7 @@ fn corner_fields(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str, widt /// [`corner_radius_row`]'s field, `width` wide. pub(crate) fn corner_radius_field(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: impl std::hash::Hash + std::fmt::Debug, width: f32) { if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), app.session.general_unit(), width) { - app.run("object.setLiveShape", json!({"radius": r})).ok(); + app.run("object.setLiveShape", json!({"radius": r.max(0.0)})).ok(); } } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index dc1e695481..b759a2df7b 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -448,7 +448,7 @@ pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live let mut set = None; egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { widgets::dim_label(ui, tl!("Sides:")); - if let Some(s) = widgets::range_field_named(ui, (id, "sides"), Some(tl!("Sides:")), f64::from(*sides), 3.0..=f64::INFINITY, "", 0, fw) { + if let Some(s) = widgets::range_field_named(ui, (id, "sides"), Some(tl!("Sides:")), f64::from(*sides), 3.0..=1000.0, "", 0, fw) { set = Some(("sides", json!(s as u64))); } ui.end_row(); diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..71724d29e9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5435,6 +5435,11 @@ length that isn't a number above 0 is an error. An ellipse (Ellipse Properties): counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse) and `invertPie: true`, which swaps them. Each call is one undo step. +`object.setLiveShape` returns a parameter error, and leaves the document and its undo history as they were, for a key +it does not take, at the top level (`` unknown parameter `path` ``) or in one of `items` +(`` unknown parameter `items[0].corner`; did you mean `items[0].corners`? ``), for a `sides` that is not a whole number +from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. + ```json {"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}} {"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..dd817d67ab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1543,6 +1543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4. Move, Rotate, Scale, Reflect, Shear, Transform Again, Nudge and the Transform panel's values (their copy, Scale Strokes & Effects, Scale Corners, Transform Patterns, Transform Objects and area type options), Align, Distribute and Distribute Spacing (Transform Patterns), Object Properties (visible, locked, Isolate Blending and Opacity & Mask Define Knockout Shape) and Live Shape Properties (Invert Pie, Make Sides Equal and relative rounding) refuse a value other than true, false or null, such as "false", and leave the document as it was. Object Properties (its opacity), Live Shape Properties (corner radius and polygon sides), the Transform panel's reference point and Align, Distribute and Distribute Spacing to an artboard (the artboard's number) refuse a value other than a number or null, such as "50", and leave the document as it was. + Live Shape Properties refuses a key it does not take, at the top level or in one of its items, a polygon side count that is not a whole number from 3 to 1000 and a negative corner radius, and leaves the document as it was; the Transform panel's Sides field takes 3 to 1000. Transform Each (its scales, moves and rotation), Rasterize (its resolution), Create Object Mosaic (its new width and height and its spacing), Create Trim Marks (its offset, length, bleed and weight) and Rearrange All Artboards (its spacing) refuse a value other than a number or null, such as "50", and change nothing. Crop Image refuses a rectangle that is not four numbers, such as "0, 0, 20, 20". Transform Each (Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale Corners, Transform Objects and Transform Patterns), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column, Move Artwork with Artboard and Transform Patterns) refuse a value other than true, false or null, such as "false", and change nothing. Create Trim Marks refuses a style that is not text, such as 5. New Artboard refuses a position or size other than a number or null, such as "10", and New Artboard and Artboard Options refuse a name other than text or null, such as 5; each leaves the artboards and the undo history as they were. From 1eaf421e696698585cc3d09cc56739a74efef5d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:58:00 -0800 Subject: [PATCH 18/27] Cutting Tools: Banner Hems and Grommets marks a banner's hems and grommet holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… (`addon.cutting.banner`) marks where a printed banner is hemmed and where its grommets go. The banner is the bounds of the selected art, or each artboard when nothing is selected. On each chosen side the command adds a hem line a set depth inside the edge (1 in by default) and grommet marks a set inset inside it (0.5 in): one at each end of the side and the rest spread evenly, as few as keep them at most a set spacing apart (24 in). A corner that two chosen sides share gets one grommet. Each grommet mark is a circle (27 pt by default) with a cross through its center. The marks are stroked in a chosen swatch ([Registration] by default) with no fill, in one group named Banner Marks per banner on top of the current layer, selected, as one undo step. While a clip group is edited in isolation, the group goes outside the outermost clip group, as Weed Box and Lines does. The command refuses every side off, no hem and no grommets, and bad params with a message. It also refuses a hem or inset that reaches the far edge of the banner when one of two opposite sides is chosen, or that meets the marks of the opposite side when both are chosen, an inset of more than half a chosen side's length, and more than 100000 grommets. The dialog has Preview. The grommets' distance from the edge is `grommetInset`, so the dialog shows it as Grommet Inset with its own catalog rows. The new Diameter catalog rows also translate the Diameter field of the `shape.flare` command dialog, which had no rows. Docs: docs/mcp.md (Cutting Tools add-on), docs/target-app-parity.md and ROADMAP. --- ROADMAP.md | 1 + crates/engine/src/cmd/addons/cutting.rs | 6 +- .../engine/src/cmd/addons/cutting/banner.rs | 228 +++++++++++++++ .../src/cmd/addons/cutting/banner/tests.rs | 263 ++++++++++++++++++ crates/engine/src/cmd/addons/cutting/weed.rs | 2 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 5 + crates/ui-egui/src/addons/cutting.rs | 11 + crates/ui-egui/src/addons/cutting/tests.rs | 67 +++++ crates/ui-egui/src/dialogs/command.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/bs.tsv | 6 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hi.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ka.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + docs/mcp.md | 37 ++- docs/target-app-parity.md | 4 + 39 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/addons/cutting/banner.rs create mode 100644 crates/engine/src/cmd/addons/cutting/banner/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index fe325ff849..fe038ec97a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,6 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. +- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 2734ca10b3..37e9f2fa79 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,9 +1,10 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together //! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art -//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are -//! the UI's. +//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner +//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's. +mod banner; mod stencil; mod weed; @@ -12,5 +13,6 @@ use super::super::CommandSpec; pub fn specs() -> Vec { let mut v = stencil::specs(); v.extend(weed::specs()); + v.extend(banner::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/banner.rs b/crates/engine/src/cmd/addons/cutting/banner.rs new file mode 100644 index 0000000000..f4ae71ed54 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner.rs @@ -0,0 +1,228 @@ +//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools +//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line, +//! where the edge is folded back and sewn (), and grommet +//! marks, where rings are set into the material to hang it +//! (), spread evenly at most a set spacing apart. The +//! banner is the selected art's bounds, or each artboard. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::length_given; +use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param}; +use super::weed::outside_clips; +use crate::{Result, Session}; + +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; +/// The sides of a banner, in the order the grommets go round it. +const SIDES: [&str; 4] = ["top", "right", "bottom", "left"]; +/// The largest hem, inset or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most grommets one run adds. +const MAX_GROMMETS: usize = 100_000; +/// Two grommet centers closer than this are one grommet (the corner that two sides share). +const SAME: f64 = 1e-6; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.banner", + "Banner Hems and Grommets…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}", + has_doc, + banner + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// The length `p` gives for `key` (0.01..1000), or `default`. +fn size(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.01..=1000.0).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))), + } +} + +/// Which sides are on, in [`SIDES`] order. +type Sides = [bool; 4]; + +/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside +/// the banner, and when both edges are chosen the two sets stay apart. +fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool { + match (near, far) { + (true, true) => 2.0 * depth < len, + (true, false) | (false, true) => depth < len, + (false, false) => true, + } +} + +/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the +/// other. +fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> { + if hem <= 0.0 { + return vec![]; + } + let lines = [ + (Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)), + (Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)), + (Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)), + (Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)), + ]; + lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect() +} + +/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end +/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them +/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two +/// chosen sides share has one grommet. `None` when there would be more than `limit`. +fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option> { + let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset); + // Each side from its start to its end, clockwise. + let runs = [ + (Point::new(l, t), Point::new(rt, t)), + (Point::new(rt, t), Point::new(rt, b)), + (Point::new(rt, b), Point::new(l, b)), + (Point::new(l, b), Point::new(l, t)), + ]; + let mut out: Vec = vec![]; + for ((a, z), on) in runs.into_iter().zip(sides) { + if !on { + continue; + } + let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0); + if !gaps.is_finite() || gaps > limit as f64 { + return None; + } + let gaps = gaps as usize; + for i in 0..=gaps { + let p = a.lerp(z, i as f64 / gaps as f64); + // A side starts where the one before it ends, and the last one ends where the first + // starts. + let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME); + if !shared(out.last()) && !shared(out.first()) { + out.push(p); + } + if out.len() > limit { + return None; + } + } + } + Some(out) +} + +fn banner(s: &mut Session, p: &Value) -> Result { + let hem = distance(p, "hem", 72.0)?; + let inset = distance(p, "grommetInset", 36.0)?; + let spacing = match length_given(p, "spacing", BANNER)? { + None => 1728.0, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => v, + Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + let diameter = size(p, "diameter", 27.0)?; + let weight = size(p, "weight", 0.3)?; + let with_grommets = switch_or(p, "grommets", true, BANNER)?; + let mut sides = [true; 4]; + for (on, key) in sides.iter_mut().zip(SIDES) { + *on = switch_or(p, key, true, BANNER)?; + } + if !sides.contains(&true) { + return Err(bad(BANNER, "turn on at least one side")); + } + if hem == 0.0 && !with_grommets { + return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark")); + } + let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?; + let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?; + let banners: Vec = if all { + s.doc()?.doc.artboards.iter().map(|a| a.rect).collect() + } else { + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(BANNER, "select the banner, or mark every artboard")); + } + s.doc()?.doc.bounds_of(&roots, false).into_iter().collect() + }; + if banners.is_empty() { + return Err(bad(BANNER, "there is no banner to mark")); + } + let [top, right, bottom, left] = sides; + let mut marks = vec![]; + let mut total = 0; + for r in &banners { + let (w, h) = (r.width(), r.height()); + if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad(BANNER, "the banner has no width or height")); + } + // A hem line must stay inside the banner and apart from the hem line on the far side. + if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) { + return Err(bad(BANNER, "the hem is too deep for the banner")); + } + let centers = if with_grommets { + // The grommets on a side run from `inset` past one end to `inset` before the other, + // stay inside the banner and stay apart from the grommets on the far side. + let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h); + if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) { + return Err(bad(BANNER, "the grommet inset is too large for the banner")); + } + grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total) + .ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))? + } else { + vec![] + }; + total += centers.len(); + marks.push((hem_lines(*r, hem, sides), centers)); + } + let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?); + let ap = Appearance::basic(Paint::None, stroke, weight); + let half = diameter / 2.0; + let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| { + let (mut ids, mut hems) = (vec![], 0); + for (lines, centers) in &marks { + let mut children = vec![]; + for (a, b) in lines { + let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()); + n.name = Some("Hem".into()); + children.push(Arc::new(n)); + } + hems += lines.len(); + for c in centers { + let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone()); + let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone()); + let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]); + g.name = Some("Grommet".into()); + children.push(Arc::new(g)); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Banner Marks".into()); + ids.push(g.id); + d.insert(parent, usize::MAX, g)?; + } + sel.set(ids.iter().copied()); + Ok((ids, hems)) + })?; + let first = ids.first().map(|i| i.0); + Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::>(), "hems": hems, "grommets": total })) +} diff --git a/crates/engine/src/cmd/addons/cutting/banner/tests.rs b/crates/engine/src/cmd/addons/cutting/banner/tests.rs new file mode 100644 index 0000000000..b186720b45 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner/tests.rs @@ -0,0 +1,263 @@ +//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::{grommet_centers, hem_lines}; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Banner Hems and Grommets with `p` and gives the first group it made. +fn banner(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.banner", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +fn round(p: Point) -> (f64, f64) { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + (r(p.x), r(p.y)) +} + +/// The hem lines in group `g` as their two ends. +fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + g.children() + .unwrap() + .iter() + .filter(|c| c.name.as_deref() == Some("Hem")) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a hem line is one straight segment"); + (round(a[0]), round(a[1])) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +/// The grommet marks in group `g` as their centers. +fn grommets(g: &Node) -> Vec<(f64, f64)> { + g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect() +} + +#[test] +fn hems_and_grommets_go_round_a_selected_banner() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8})); + assert_eq!(g.name.as_deref(), Some("Banner Marks")); + assert_eq!( + hems(&g), + vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))] + ); + // 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the + // corners are shared. + assert_eq!( + grommets(&g), + vec![ + (10.0, 10.0), + (136.667, 10.0), + (263.333, 10.0), + (390.0, 10.0), + (390.0, 100.0), + (390.0, 190.0), + (263.333, 190.0), + (136.667, 190.0), + (10.0, 190.0), + (10.0, 100.0) + ] + ); + assert_eq!(r["hems"], 4); + assert_eq!(r["grommets"], 10); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); +} + +#[test] +fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5})); + let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap(); + assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0))); + let parts = first.children().unwrap(); + assert_eq!(parts.len(), 3, "a circle and two lines"); + for c in parts.iter() { + assert!(matches!(c.kind, NodeKind::Path { .. }), "a path"); + assert_eq!(c.appearance.fill_paint(), Paint::None); + assert!(c.appearance.stroke_paint().is_registration()); + assert_eq!(c.appearance.stroke_width(), 0.5); + } +} + +#[test] +fn every_artboard_is_marked_when_nothing_is_selected() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let second = node(&s, ids[1]); + assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt"); + assert!(grommets(&second).is_empty()); + assert_eq!(r["grommets"], 0); +} + +#[test] +fn only_the_chosen_sides_are_marked() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false})); + assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]); + assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]); +} + +#[test] +fn units_are_read_and_bad_params_are_refused() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 400.0); + let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0))); + assert_eq!(grommets(&g)[0], (36.0, 36.0)); + let before = s.doc().unwrap().history.undo.len(); + for p in [ + json!({"hem": -1}), + json!({"hem": "x"}), + json!({"spacing": 0}), + json!({"diameter": 0}), + json!({"weight": 1e9}), + json!({"top": "yes"}), + json!({"top": false, "right": false, "bottom": false, "left": false}), + json!({"hem": 0, "grommets": false}), + json!({"hem": 250}), + json!({"grommetInset": 250}), + json!({"spacing": 0.001}), + json!({"swatch": "no such swatch"}), + ] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing"); +} + +#[test] +fn hems_and_grommets_on_one_side_may_go_past_the_middle() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000}); + let with = |extra: Value| { + let mut p = one.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70}))); + assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]); + assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]); + for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends"); + } + // A grommet inset of half the width puts the grommets of a side on one point: one grommet. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 300.0); + let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10}))); + assert_eq!(grommets(&g), vec![(50.0, 50.0)]); +} + +#[test] +fn hems_and_grommets_on_opposite_sides_stay_apart() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let pair = |p: Value| { + let mut q = json!({"right": false, "left": false, "spacing": 1000}); + q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone()); + q + }; + // Half the height would put the top and bottom marks on the same line. + let refused = [ + pair(json!({"hem": 50})), + pair(json!({"hem": 0, "grommetInset": 50})), + json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}), + ]; + for p in refused { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5}))); + assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]); + assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]); +} + +#[test] +fn the_marks_go_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (g, art) = s + .edit("clip group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default()); + let art_id = art.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + Ok((id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false})); + // The clipping path would hide marks inside the clip group. + assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent); + assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner"); +} + +#[test] +fn all_artboards_marks_the_artboards_even_with_a_selection() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0))); +} + +#[test] +fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() { + let mut s = session(); + assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err()); +} + +#[test] +fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap(); + assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]); + // Spacing that divides the side exactly does not add a grommet, even when the division + // rounds up (2.1 / 0.3 is 7.000000000000001). + let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap(); + assert_eq!(c.len(), 8); + assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit"); + assert!(hem_lines(r, 0.0, [true; 4]).is_empty()); +} diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs index 8a6375a595..183a66ad57 100644 --- a/crates/engine/src/cmd/addons/cutting/weed.rs +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option, spacing: Option) -> Resu /// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping /// sublayer (a group edited in isolation), the container around the outermost of them. A clipping /// path would hide the lines and the box, which lie outside the art. -fn outside_clips(d: &Document, parent: Option) -> Option { +pub(super) fn outside_clips(d: &Document, parent: Option) -> Option { let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); let (mut at, mut out) = (parent, parent); while let Some(p) = at { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 21d9d65c3d..b530b21d51 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.cutting.banner", "addon.cutting.bridges", "addon.cutting.weed", "addon.image.applyColorMask", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index d88d99665f..032ef5954d 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -901,6 +901,11 @@ fn structured_junk() { ("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})), ("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})), ("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})), + ("addon.cutting.banner", json!({"hem": 1e5, "grommetInset": 1e5, "spacing": 1e-300, "diameter": 1000})), + ("addon.cutting.banner", json!({"hem": -1, "spacing": f64::MAX, "weight": 1e308, "top": "no", "allArtboards": 1})), + ("addon.cutting.banner", json!({"hem": 0, "grommetInset": 0, "spacing": 0.01, "allArtboards": true})), + ("addon.cutting.banner", json!({"spacing": "1 mm", "grommets": false, "swatch": 5, "left": null})), + ("addon.cutting.banner", json!({"top": false, "right": false, "bottom": false, "left": false, "allArtboards": false})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 1fbdcf88c2..15a6790872 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -2,6 +2,7 @@ //! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options //! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, //! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. +//! Banner Hems and Grommets… opens it on the options of `addon.cutting.banner`, with a Preview. use serde_json::json; @@ -15,6 +16,8 @@ pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Hor pub const WEED: &str = "addon.cutting.weed"; /// Weed Box and Lines › Direction: what the command's `direction` takes, by name. pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { @@ -25,6 +28,14 @@ pub fn menu() -> Vec { WEED, json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}), ), + Item::Cmd( + "Banner Hems and Grommets…", + BANNER, + json!({ + "hem": 72, "top": true, "right": true, "bottom": true, "left": true, "grommets": true, "spacing": 1728, "grommetInset": 36, + "diameter": 27, "weight": 0.3, "swatch": "[Registration]" + }), + ), ] } diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index 2e69eab904..99580b369c 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -143,3 +143,70 @@ fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { app.run("edit.undo", json!({})).unwrap(); } } + +/// Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… opens the command dialog on its +/// defaults with Preview off, previews the marks, takes them away on Cancel and keeps them as one +/// step on OK. With nothing selected it marks the artboard. +#[test] +fn banner_hems_and_grommets_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count(); + let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Banner Hems and Grommets…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::BANNER.to_string(), "Banner Hems and Grommets".to_string())); + assert_eq!((d.bool("grommets"), d.bool("top"), d.bool("preview")), (true, true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in [ + "Banner Hems and Grommets", + "Hem", + "Top", + "Right", + "Bottom", + "Left", + "Grommets", + "Spacing", + "Grommet Inset", + "Diameter", + "Weight", + "Swatch", + "Preview", + ] { + assert!(shown.contains(s), "{s} in {shown}"); + } + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Bannersäume und Ösen", "Saum:", "Ösen:", "Randabstand der Ösen:", "Durchmesser:"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(count(&app), nodes, "no preview without the checkbox"); + + // 600 × 400 pt with grommets at most 1728 pt apart: one at each corner. The group holds four + // hem lines and four grommets of three paths each. + let added = 1 + 4 + 4 * 4; + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_eq!(count(&app), nodes + added, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + open(&mut app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!("100 pt")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1, "one undo step"); + // 528 pt along the top and bottom need 6 gaps, 328 pt down the sides 4: 20 grommets. + assert_eq!(count(&app), nodes + 1 + 4 + 20 * 4); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 948b6f990f..cb23628a4c 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -65,7 +65,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path /// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask and -/// Cutting Tools › Weed Box and Lines. (Cancel rolls a preview back whichever dialog started it.) +/// Cutting Tools › Weed Box and Lines and Banner Hems and Grommets. (Cancel rolls a preview back +/// whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), @@ -73,7 +74,8 @@ fn previews(d: &Dialog) -> bool { | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK - | crate::addons::cutting::WEED => true, + | crate::addons::cutting::WEED + | crate::addons::cutting::BANNER => true, _ => false, } } @@ -120,6 +122,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], + crate::addons::cutting::BANNER => &["hem", "spacing", "grommetInset", "diameter", "weight"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -134,6 +137,9 @@ fn lengths(command: &str) -> &'static [&'static str] { fn order(command: &str) -> &'static [&'static str] { match command { crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"], + crate::addons::cutting::BANNER => { + &["hem", "top", "right", "bottom", "left", "grommets", "spacing", "grommetInset", "diameter", "weight", "swatch"] + } // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4c2785956f..21a1e2d943 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6827,3 +6827,9 @@ breakText Glyphs Глифи Box Рамка Swatch Образец Direction Посока + Banner Hems and Grommets… Подгъви и капси за банер… + Banner Hems and Grommets Подгъви и капси за банер + Hem Подгъв + Grommets Капси + Grommet Inset Отстъп на капсите от ръба + Diameter Диаметър diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9ccc94d3a4..20e3fded9e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6880,3 +6880,9 @@ shape Open Otvoren Box Okvir Swatch Uzorak boje Direction Smjer + Banner Hems and Grommets… Porubi i ušice za baner… + Banner Hems and Grommets Porubi i ušice za baner + Hem Porub + Grommets Ušice + Grommet Inset Udaljenost ušica od ruba + Diameter Prečnik diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b0159ebc14..a7dbc93ccd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7414,3 +7414,9 @@ breakText Glyphs Glifs Margin Marge Box Caixa Swatch Mostra + Banner Hems and Grommets… Vores i ullets de pancarta… + Banner Hems and Grommets Vores i ullets de pancarta + Hem Vora + Grommets Ullets + Grommet Inset Distància dels ullets a la vora + Diameter Diàmetre diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 27b2d32771..601e95f741 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7433,3 +7433,9 @@ breakText Glyphs Glyfy Margin Okraj Box Rámeček Swatch Vzorek + Banner Hems and Grommets… Lemy a průchodky banneru… + Banner Hems and Grommets Lemy a průchodky banneru + Hem Lem + Grommets Průchodky + Grommet Inset Odstup průchodek od okraje + Diameter Průměr diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3810bb2ac8..54eaa336af 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6842,3 +6842,9 @@ breakText Glyphs Glyffer Box Ramme Swatch Farveprøve Direction Retning + Banner Hems and Grommets… Bannersømme og maljer… + Banner Hems and Grommets Bannersømme og maljer + Hem Søm + Grommets Maljer + Grommet Inset Maljernes afstand fra kanten + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b6cd3e8a6..13536f7aac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7029,3 +7029,9 @@ breakText Glyphs Glyphen Margin Rand Box Rahmen Swatch Farbfeld + Banner Hems and Grommets… Bannersäume und Ösen… + Banner Hems and Grommets Bannersäume und Ösen + Hem Saum + Grommets Ösen + Grommet Inset Randabstand der Ösen + Diameter Durchmesser diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8620169f21..b7ce9a8df8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7386,3 +7386,9 @@ breakText Glyphs Γλύφοι Margin Περιθώριο Box Πλαίσιο Swatch Δείγμα χρώματος + Banner Hems and Grommets… Στριφώματα και τρουκς πανό… + Banner Hems and Grommets Στριφώματα και τρουκς πανό + Hem Στρίφωμα + Grommets Τρουκς + Grommet Inset Απόσταση τρουκς από την άκρη + Diameter Διάμετρος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a82db20a99..675132efa7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7377,3 +7377,9 @@ breakText Glyphs Glifos Margin Margen Box Caja Swatch Muestra + Banner Hems and Grommets… Dobladillos y ojales de pancarta… + Banner Hems and Grommets Dobladillos y ojales de pancarta + Hem Dobladillo + Grommets Ojales + Grommet Inset Distancia de los ojales al borde + Diameter Diámetro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index db90a36316..28edeca855 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7468,3 +7468,9 @@ breakText Glyphs Merkit Margin Marginaali Box Kehys Swatch Värimalli + Banner Hems and Grommets… Bannerin päärmeet ja purjerenkaat… + Banner Hems and Grommets Bannerin päärmeet ja purjerenkaat + Hem Päärme + Grommets Purjerenkaat + Grommet Inset Purjerenkaiden etäisyys reunasta + Diameter Halkaisija diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 938e43dd48..970ac45bb6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7428,3 +7428,9 @@ breakText Glyphs Glyphes Margin Marge Box Cadre Swatch Nuance + Banner Hems and Grommets… Ourlets et œillets de banderole… + Banner Hems and Grommets Ourlets et œillets de banderole + Hem Ourlet + Grommets Œillets + Grommet Inset Distance des œillets au bord + Diameter Diamètre diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0eacf9629b..6898342992 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8825,3 +8825,9 @@ theme Appearance अपीयरेंस Margin मार्जिन Box बॉक्स Swatch स्वॉच + Banner Hems and Grommets… बैनर हेम और आईलेट… + Banner Hems and Grommets बैनर हेम और आईलेट + Hem हेम + Grommets आईलेट + Grommet Inset किनारे से आईलेट की दूरी + Diameter व्यास diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3dadc4d3b6..734c34c7bd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7417,3 +7417,9 @@ breakText Glyphs Glifák Margin Margó Box Keret Swatch Színminta + Banner Hems and Grommets… Molinó szegései és ringlijei… + Banner Hems and Grommets Molinó szegései és ringlijei + Hem Szegés + Grommets Ringlik + Grommet Inset Ringlik távolsága a széltől + Diameter Átmérő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e670840b2c..6ed3f8bb9b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6842,3 +6842,9 @@ breakText Glyphs Glif Box Kotak Swatch Sampel warna Direction Arah + Banner Hems and Grommets… Lipatan dan Mata Ayam Spanduk… + Banner Hems and Grommets Lipatan dan Mata Ayam Spanduk + Hem Lipatan + Grommets Mata ayam + Grommet Inset Jarak mata ayam dari tepi + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 91d1e24bf7..758dabf3d0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7063,3 +7063,9 @@ breakText Glyphs Glifi Margin Margine Box Riquadro Swatch Campione + Banner Hems and Grommets… Orli e occhielli per striscioni… + Banner Hems and Grommets Orli e occhielli per striscioni + Hem Orlo + Grommets Occhielli + Grommet Inset Distanza degli occhielli dal bordo + Diameter Diametro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 628d1ef3de..dadefa6fab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7129,3 +7129,9 @@ breakText Glyphs 字形 Margin マージン Box 枠 Swatch スウォッチ + Banner Hems and Grommets… バナーの折り返しとハトメ… + Banner Hems and Grommets バナーの折り返しとハトメ + Hem 折り返し + Grommets ハトメ + Grommet Inset 端からハトメまでの距離 + Diameter 直径 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index aa2c27c296..57bc564d51 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7547,3 +7547,9 @@ font-script Japanese იაპონური Margin ზღვარი Box ჩარჩო Swatch ფერის ნიმუში + Banner Hems and Grommets… ბანერის ნაკეცები და ლუვერები… + Banner Hems and Grommets ბანერის ნაკეცები და ლუვერები + Hem ნაკეცი + Grommets ლუვერები + Grommet Inset ლუვერების დაშორება კიდიდან + Diameter დიამეტრი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5c2ac8d1..85713a83b2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7024,3 +7024,9 @@ breakText Glyphs 글리프 Margin 여백 Box 상자 Swatch 견본 + Banner Hems and Grommets… 배너 단 접기 및 아일렛… + Banner Hems and Grommets 배너 단 접기 및 아일렛 + Hem 단 접기 + Grommets 아일렛 + Grommet Inset 가장자리에서 아일렛까지 거리 + Diameter 지름 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d46d7e8e87..282a3b2621 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7460,3 +7460,9 @@ breakText Glyphs Glyphs Box Kader Swatch Staal Direction Richting + Banner Hems and Grommets… Bannerzomen en zeilringen… + Banner Hems and Grommets Bannerzomen en zeilringen + Hem Zoom + Grommets Zeilringen + Grommet Inset Afstand van zeilringen tot de rand + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 38d5e2f497..c8753c0049 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7290,3 +7290,9 @@ breakText Glyphs Glify Margin Margines Box Ramka Swatch Próbka + Banner Hems and Grommets… Obszycia i oczka banera… + Banner Hems and Grommets Obszycia i oczka banera + Hem Obszycie + Grommets Oczka + Grommet Inset Odległość oczek od krawędzi + Diameter Średnica diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2a45bc8e68..c7f262c1d1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7506,3 +7506,9 @@ swatch Yellow Green Verde-amarelado Margin Margem Box Caixa Swatch Amostra + Banner Hems and Grommets… Bainhas e ilhoses de banner… + Banner Hems and Grommets Bainhas e ilhoses de banner + Hem Bainha + Grommets Ilhoses + Grommet Inset Distância dos ilhoses à borda + Diameter Diâmetro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af93b86974..0b86055e36 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7522,3 +7522,9 @@ shape Open Открытый Margin Поле Box Рамка Swatch Образец + Banner Hems and Grommets… Подгибы и люверсы баннера… + Banner Hems and Grommets Подгибы и люверсы баннера + Hem Подгиб + Grommets Люверсы + Grommet Inset Отступ люверсов от края + Diameter Диаметр diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f26438a201..3c5279013c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8909,3 +8909,9 @@ digits Hindi Hindi Stroke Weight Up Öka konturtjocklek The selected objects have different graphics. De markerade objekten har olika grafik. Word Gap Ordmellanrum + Banner Hems and Grommets… Bannerfållar och öljetter… + Banner Hems and Grommets Bannerfållar och öljetter + Hem Fåll + Grommets Öljetter + Grommet Inset Öljetternas avstånd från kanten + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a91aab6f0e..737ba45d1c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7389,3 +7389,9 @@ breakText Glyphs Glifler Margin Kenar Boşluğu Box Kutu Swatch Renk Örneği + Banner Hems and Grommets… Afiş Kenar Kıvırma ve Kuşgözleri… + Banner Hems and Grommets Afiş Kenar Kıvırma ve Kuşgözleri + Hem Kenar Kıvırma + Grommets Kuşgözleri + Grommet Inset Kuşgözlerinin kenara uzaklığı + Diameter Çap diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17ddeb2086..08f90c2100 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7043,3 +7043,9 @@ breakText Glyphs Гліфи Margin Поле Box Рамка Swatch Зразок + Banner Hems and Grommets… Підгини та люверси банера… + Banner Hems and Grommets Підгини та люверси банера + Hem Підгин + Grommets Люверси + Grommet Inset Відступ люверсів від краю + Diameter Діаметр diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7d3d89a82d..b4937f73f1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7044,3 +7044,9 @@ breakText Glyphs Glyph Margin Lề Box Khung Swatch Mẫu màu + Banner Hems and Grommets… Viền gấp và khoen băng rôn… + Banner Hems and Grommets Viền gấp và khoen băng rôn + Hem Viền gấp + Grommets Khoen + Grommet Inset Khoảng cách từ mép đến khoen + Diameter Đường kính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 34ffeb5d6b..847fa36536 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7272,3 +7272,9 @@ breakText Glyphs 字形 Margin 边距 Box 框 Swatch 色板 + Banner Hems and Grommets… 横幅折边和扣眼… + Banner Hems and Grommets 横幅折边和扣眼 + Hem 折边 + Grommets 扣眼 + Grommet Inset 扣眼边距 + Diameter 直径 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 309e00346c..390125a2b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7079,3 +7079,9 @@ breakText Glyphs 字符 Margin 邊距 Box 框 Swatch 色票 + Banner Hems and Grommets… 橫幅折邊與扣眼… + Banner Hems and Grommets 橫幅折邊與扣眼 + Hem 折邊 + Grommets 扣眼 + Grommet Inset 扣眼邊距 + Diameter 直徑 diff --git a/docs/mcp.md b/docs/mcp.md index 2b20cfc588..d1dd2cec85 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10728,9 +10728,9 @@ it ends up in front), baked with `effect.expandAppearance`, then `object.pathfin ## Cutting Tools add-on -Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges` and -`addon.cutting.weed`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil Bridges… and Weed Box and -Lines…. +Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges`, +`addon.cutting.weed` and `addon.cutting.banner`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil +Bridges…, Weed Box and Lines… and Banner Hems and Grommets…. ### Stencil Bridges @@ -10820,6 +10820,37 @@ dialog with Preview; a Spacing above 0 is used instead of the Count. {"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}} ``` +### Banner Hems and Grommets + +`addon.cutting.banner {hem?, grommets?, spacing?, grommetInset?, diameter?, top?, right?, bottom?, left?, weight?, +swatch?, allArtboards?}` marks where a printed banner is hemmed, its edge folded back and sewn +([hem](https://en.wikipedia.org/wiki/Hem)), and where grommets, rings set into the material to hang it, are punched +([grommet](https://en.wikipedia.org/wiki/Grommet)). The ranges, defaults and rules below are VectorCraft's own. The banner +is the bounds of the selected art, or each artboard when `allArtboards` is true (the default when nothing is selected). +`top`, `right`, `bottom` and `left` (each true by default) choose the sides that get marks. On each chosen side a hem +line runs the whole length of the edge, `hem` points inside it (0 to 100000, default 72; 0 leaves the hem lines out). +With `grommets` on (the default), each chosen side gets grommet marks `grommetInset` points inside the edge (0 to +100000, default 36): one at each end of the side, `grommetInset` from the edges across it, and the rest spread evenly +between them, as few as keep them at most `spacing` points apart (more than 0 and at most 100000, default 1728, 24 in). A corner that two +chosen sides share has one grommet. Each grommet mark is a circle `diameter` points wide (0.01 to 1000, default 27) with +a cross through its center. The marks are stroked `weight` points wide (0.01 to 1000, default 0.3) in the color swatch +`swatch` (default `"[Registration]"`), with no fill. + +Each banner gets one group named "Banner Marks", holding its hem lines (named "Hem") and its grommets (groups named +"Grommet"), added on top of the current layer and selected, as one undo step. While a clip group, or a group inside one, +is edited in isolation, the groups go on top of the container that holds the outermost clip group around it. The command +fails with every side off, with no hem and no grommets, with nothing selected while `allArtboards` is false, when a +banner has no width or height, when a `hem` above 0 reaches the far edge of the banner (the full height with only one +of the top and bottom chosen, or the full width with only one of the left and right) or meets the hem on the opposite +side (half the height or width with both chosen), when a `grommetInset` does the same for the grommets or is more than half +the length of a chosen side, and when the grommets would be more than 100000. Result: +`{id, ids, hems, grommets}`: the first group's id, every group's id, and the number of hem lines and grommet marks. +Banner Hems and Grommets… opens the command dialog with Preview. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.banner","params":{"hem":"1 in","grommetInset":"0.5 in","spacing":"2 ft"}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7782bdfcd..54cab41bab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2128,6 +2128,10 @@ A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Cutting Tools › Weed Box and Lines… (`addon.cutting.weed`) adds a weed box and weeding lines around the selected art for vinyl cutting ([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The rest is VectorCraft's own: the box is a rectangle at a margin around the art, and the lines run across it horizontally, vertically or both ways, by count or by spacing, and stop at the art's outlines or at a gap from them. The box and the lines are straight paths stroked in a chosen swatch ([Registration] by default), in one group named Weeding on top of the current layer, added as one undo step. The art is measured by the outlines a cutter cuts: compound paths by their fill rule, type by its glyph outlines, clip groups by what shows, images by their frames, placed files by the art they show, symbol instances by their art, and blends and other live objects by the art they draw. The dialog has Preview. Tests: `cmd::addons::cutting::weed::tests` in `vectorcraft-engine`, `weed_box_and_lines_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Weed Box and Lines. +## Cutting Tools add-on: Banner Hems and Grommets (2026-11-13) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… (`addon.cutting.banner`) marks where a printed banner is hemmed ([hem](https://en.wikipedia.org/wiki/Hem)) and where its grommets go ([grommet](https://en.wikipedia.org/wiki/Grommet)). The rest is VectorCraft's own: the banner is the selected art's bounds or each artboard; each chosen side gets a hem line at a set depth inside its edge (1 in by default) and grommet marks at a set inset (0.5 in), one at each end of the side and the rest spread evenly at most a set spacing apart (24 in), with one grommet at a shared corner. Each grommet mark is a circle with a cross through its center. The marks are stroked in a chosen swatch ([Registration] by default), in one group named Banner Marks per banner on top of the current layer, added as one undo step. The dialog has Preview. Tests: `cmd::addons::cutting::banner::tests` in `vectorcraft-engine`, `banner_hems_and_grommets_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Banner Hems and Grommets. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. From ac10c513818dfa8b9bf51b26598cdfd4c47b6dfc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:22:07 -0800 Subject: [PATCH 19/27] Data Recovery: a failed copy names the folder in use and how to change it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a recovery copy could not be written, the status bar said only "Couldn't save recovery data: ", and a copy that failed on the background worker showed as "Saving recovery data for failed: <error>". Neither said which folder was in use or how to pick another. The message keeps the error text (and the document's title when one copy fails), then names the folder copies go to, says whether it is the default recovery folder or the folder set in Preferences, and names the setting that picks another folder: Preferences → File Handling → Data Recovery → Folder (recoveryFolder). The recovery code writes the same line to the log. The folder is the one in use when the round started, so a preference changed while a copy runs does not change the folder the message names. A copy that fails now reports its own message, so the job's result is Ok rather than an error, and the job no longer adds "<label> failed: <error>" over it. A store the host installed (browser storage) has no folder to name: its failed copy shows "Saving recovery data for <title> failed: <error>", as a copy on the worker did before, also when the copy was written without the worker. - engine: recovery::folder returns the folder in use and whether the recoveryFolder preference names it; recovery::store uses it. - Four @msg rows in each complete-message catalog, and in the bg, bs, da and id catalogs, which translate the old message. - Control replies to a client confined to read roots show a recovery folder outside them, and the paths under it, by the folder's name, as they already show other paths outside the roots. That covers the folder in use and the last eight folders a failure message named. - docs/target-app-parity.md: a line next to the Data Recovery Folder preference. --- crates/engine/src/cmd/recovery.rs | 16 +++- crates/engine/src/tests/recovery.rs | 17 ++++ crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/control/tests.rs | 23 +++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/recovery.rs | 80 +++++++++++++++-- crates/ui-egui/src/tests/recovery.rs | 125 +++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 34 files changed, 367 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index c1c7f789fe..de4d145840 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -576,13 +576,25 @@ pub fn store(s: &Session) -> Option<Arc<dyn RecoveryStore>> { } #[cfg(not(target_arch = "wasm32"))] { - let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone()); - folder.map(|f| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>) + folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>) } #[cfg(target_arch = "wasm32")] None } +/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is +/// the folder the app sets). `None` when they go to a store the host installed (browser storage) +/// or nowhere. +pub fn folder(s: &Session) -> Option<(String, bool)> { + if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") { + return None; + } + match s.prefs.recovery_folder.trim() { + "" => s.recovery.default_folder.clone().map(|f| (f, false)), + set => Some((set.to_string(), true)), + } +} + fn has_store(s: &Session) -> std::result::Result<(), String> { match store(s) { Some(_) => Ok(()), diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 976d217833..a51f73b222 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -396,6 +396,23 @@ fn an_app_that_leaves_has_its_copies_offered_at_once() { } } +/// The folder copies go to: the `recoveryFolder` preference, trimmed, when it names one, else the +/// folder the app sets; none with a store the host installed. The web build has no folders. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_folder_in_use_is_the_preference_or_the_apps() { + let mut s = Session::new(); + assert_eq!(cmd::recovery::folder(&s), None); + s.recovery.set_default_folder(Some("/app/recovery".into())); + assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false))); + s.prefs.recovery_folder = " \t ".into(); + assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false))); + s.prefs.recovery_folder = " /chosen/copies ".into(); + assert_eq!(cmd::recovery::folder(&s), Some(("/chosen/copies".into(), true))); + s.recovery.set_store(Arc::new(MemoryStore::default())); + assert_eq!(cmd::recovery::folder(&s), None); +} + #[test] fn folder_areas_are_locked_by_running_apps() { let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 89ab7e37b4..e0f7f15cd2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1223,7 +1223,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } /// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that -/// `sent`, the request's params, holds ([`handle`]). The `path` and `files` entries of the reply's +/// `sent`, the request's params, holds ([`handle`]), and the recovery folders outside the read root +/// that are in use or that a failure message named. The `path` and `files` entries of the reply's /// `result`, at any depth, that name a file the request gave inside the write roots, the file it /// wrote, stay as given ([`written_paths`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { @@ -1237,6 +1238,11 @@ fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: hiding.merge(before); let mut paths = Vec::new(); sent_paths(sent, &mut paths); + // The status text of a recovery copy that failed names the recovery folder it went to. + let current = vectorcraft_engine::cmd::recovery::folder(&app.session).map(|(f, _)| f); + for folder in app.recovery.named.iter().chain(current.as_ref()) { + collect_sent_paths(&json!(folder), true, &mut paths); + } hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); restore_written_paths(reply, kept); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index ac8fbfa3eb..a0007d0676 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -415,6 +415,29 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); } +/// `ui.inspect` shows a recovery folder outside the read root that the status text names (a failed +/// recovery copy) by its folder name, also in the paths under it: the folder in use, and one a +/// failure named before the preference moved on. A readable one stays. +#[test] +fn a_recovery_folder_outside_the_read_root_is_hidden_in_the_status() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.session.recovery.set_default_folder(Some("/outside/recovery".into())); + let root = crate::tests_folder::Folder::new("recovery-status"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); + let inspect = |app: &mut VectorcraftApp, status: String| { + app.ui.status = status; + let reply = vectorcraft_engine::file_access::confine(roots.as_ref(), || input_request(app, "ui.inspect", json!({}))); + reply["result"]["ui"]["status"].clone() + }; + let status = "Couldn't save recovery data: /outside/recovery/a-1 is not a folder. Copies go to /outside/recovery".to_string(); + assert_eq!(inspect(&mut app, status), "Couldn't save recovery data: recovery/a-1 is not a folder. Copies go to recovery"); + let inside = root.as_path().join("copies").to_string_lossy().into_owned(); + app.session.prefs.recovery_folder = inside.clone(); + app.recovery.named = vec!["/outside/earlier".into()]; + assert_eq!(inspect(&mut app, "Copies go to /outside/earlier".into()), "Copies go to earlier"); + assert_eq!(inspect(&mut app, format!("Copies go to {inside}")), json!(format!("Copies go to {inside}"))); +} + /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded /// as in one that failed. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 708ffe9db6..586732f167 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4130,6 +4130,10 @@ axis Both И двете @msg Couldn't place {name}: the document it was placed in was closed Неуспешно поставяне на {name}: документът, в който се поставяше, е затворен @msg Couldn't read {name}: {e} Неуспешно четене на {name}: {e} @msg Couldn't save recovery data: {e} Неуспешно запазване на данните за възстановяване: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Неуспешно запазване на данните за възстановяване на {title}: {e}. Копията се записват в {folder}, папката за възстановяване по подразбиране; за да използвате друга папка, задайте Предпочитания → Работа с файлове → Възстановяване на данни → Папка (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Неуспешно запазване на данните за възстановяване на {title}: {e}. Копията се записват в {folder}, папката, зададена в Предпочитания; за да използвате друга папка, задайте Предпочитания → Работа с файлове → Възстановяване на данни → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Неуспешно запазване на данните за възстановяване: {e}. Копията се записват в {folder}, папката за възстановяване по подразбиране; за да използвате друга папка, задайте Предпочитания → Работа с файлове → Възстановяване на данни → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Неуспешно запазване на данните за възстановяване: {e}. Копията се записват в {folder}, папката, зададена в Предпочитания; за да използвате друга папка, задайте Предпочитания → Работа с файлове → Възстановяване на данни → Папка (recoveryFolder) @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} парчета diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8f5ea73487..fb9aa1d176 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4494,6 +4494,10 @@ imageTrace Outline Obris @msg Couldn't place {name}: the document it was placed in was closed Nije moguće postaviti {name}: dokument u koji je postavljen je zatvoren @msg Couldn't read {name}: {e} Nije moguće pročitati {name}: {e} @msg Couldn't save recovery data: {e} Nije moguće sačuvati podatke za oporavak: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nije moguće sačuvati podatke za oporavak za {title}: {e}. Kopije se čuvaju u {folder}, zadanom folderu za oporavak; da biste koristili drugi folder, postavite Postavke → Rukovanje datotekama → Oporavak podataka → Folder (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nije moguće sačuvati podatke za oporavak za {title}: {e}. Kopije se čuvaju u {folder}, folderu postavljenom u Postavkama; da biste koristili drugi folder, postavite Postavke → Rukovanje datotekama → Oporavak podataka → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nije moguće sačuvati podatke za oporavak: {e}. Kopije se čuvaju u {folder}, zadanom folderu za oporavak; da biste koristili drugi folder, postavite Postavke → Rukovanje datotekama → Oporavak podataka → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nije moguće sačuvati podatke za oporavak: {e}. Kopije se čuvaju u {folder}, folderu postavljenom u Postavkama; da biste koristili drugi folder, postavite Postavke → Rukovanje datotekama → Oporavak podataka → Folder (recoveryFolder) @msg Create Outlines: select text objects Kreiraj obrise: odaberite tekstualne objekte @msg Create from Guides: the document has no artboard Kreiraj iz vodilica: dokument nema radnu ploču @msg Create from Guides: the guides make more than {MAX_PIECES} slices Kreiraj iz vodilica: vodilice prave više od {MAX_PIECES} isječaka diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5516c9662f..582d3d1072 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5524,6 +5524,10 @@ digits Malayalam Malaiàlam @msg Couldn't place {name}: {e} No s’ha pogut col·locar {name}: {e} @msg Couldn't read {name}: {e} No s’ha pogut llegir {name}: {e} @msg Couldn't save recovery data: {e} No s’han pogut desar les dades de recuperació: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No s’han pogut desar les dades de recuperació de {title}: {e}. Les còpies es desen a {folder}, la carpeta de recuperació per defecte; per fer servir una altra carpeta, configureu Preferències → Gestió de fitxers → Recuperació de dades → Carpeta (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No s’han pogut desar les dades de recuperació de {title}: {e}. Les còpies es desen a {folder}, la carpeta definida a Preferències; per fer servir una altra carpeta, configureu Preferències → Gestió de fitxers → Recuperació de dades → Carpeta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No s’han pogut desar les dades de recuperació: {e}. Les còpies es desen a {folder}, la carpeta de recuperació per defecte; per fer servir una altra carpeta, configureu Preferències → Gestió de fitxers → Recuperació de dades → Carpeta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No s’han pogut desar les dades de recuperació: {e}. Les còpies es desen a {folder}, la carpeta definida a Preferències; per fer servir una altra carpeta, configureu Preferències → Gestió de fitxers → Recuperació de dades → Carpeta (recoveryFolder) @msg Create from Guides: the document has no artboard Crea a partir de guies: el document no té cap tauler @msg Create from Guides: the guides make more than {MAX_PIECES} slices Crea a partir de guies: les guies fan més de {MAX_PIECES} talls @msg Create from Selection: the selection has no area Crea a partir de la selecció: la selecció no té àrea diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d03d192a3a..396ad832ce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4651,6 +4651,10 @@ imageTrace Outline Obrys @msg Couldn't place {name}: the document it was placed in was closed {name} nelze umístit: dokument, do kterého se umísťoval, byl zavřen @msg Couldn't read {name}: {e} {name} nelze přečíst: {e} @msg Couldn't save recovery data: {e} Data pro obnovení nelze uložit: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Data pro obnovení dokumentu {title} nelze uložit: {e}. Kopie se ukládají do {folder}, výchozí složky pro obnovení; chcete-li použít jinou složku, nastavte Předvolby → Práce se soubory → Obnovení dat → Složka (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Data pro obnovení dokumentu {title} nelze uložit: {e}. Kopie se ukládají do {folder}, složky nastavené v Předvolbách; chcete-li použít jinou složku, nastavte Předvolby → Práce se soubory → Obnovení dat → Složka (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Data pro obnovení nelze uložit: {e}. Kopie se ukládají do {folder}, výchozí složky pro obnovení; chcete-li použít jinou složku, nastavte Předvolby → Práce se soubory → Obnovení dat → Složka (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Data pro obnovení nelze uložit: {e}. Kopie se ukládají do {folder}, složky nastavené v Předvolbách; chcete-li použít jinou složku, nastavte Předvolby → Práce se soubory → Obnovení dat → Složka (recoveryFolder) @msg Create Outlines: select text objects Převést na křivky: vyberte textové objekty @msg Create from Guides: the document has no artboard Vytvořit z vodítek: dokument nemá žádnou pracovní plochu @msg Create from Guides: the guides make more than {MAX_PIECES} slices Vytvořit z vodítek: vodítka vytvářejí více než {MAX_PIECES} řezů diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00c85aaeb6..c980bba93f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4248,6 +4248,10 @@ imageTrace Outline Kontur @msg Couldn't place {name}: the document it was placed in was closed Kunne ikke placere {name}: dokumentet, den blev placeret i, var lukket @msg Couldn't read {name}: {e} Kunne ikke læse {name}: {e} @msg Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunne ikke gemme gendannelsesdata for {title}: {e}. Kopierne gemmes i {folder}, standardmappen til gendannelse; angiv Præferencer → Filhåndtering → Datagendannelse → Mappe (recoveryFolder) for at bruge en anden mappe +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunne ikke gemme gendannelsesdata for {title}: {e}. Kopierne gemmes i {folder}, mappen angivet i Præferencer; angiv Præferencer → Filhåndtering → Datagendannelse → Mappe (recoveryFolder) for at bruge en anden mappe +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunne ikke gemme gendannelsesdata: {e}. Kopierne gemmes i {folder}, standardmappen til gendannelse; angiv Præferencer → Filhåndtering → Datagendannelse → Mappe (recoveryFolder) for at bruge en anden mappe +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunne ikke gemme gendannelsesdata: {e}. Kopierne gemmes i {folder}, mappen angivet i Præferencer; angiv Præferencer → Filhåndtering → Datagendannelse → Mappe (recoveryFolder) for at bruge en anden mappe @msg Create Outlines: select text objects Opret konturer: markér tekstobjekter @msg Create from Guides: the document has no artboard Opret ud fra hjælpelinjer: dokumentet har ingen tegneflade @msg Create from Guides: the guides make more than {MAX_PIECES} slices Opret ud fra hjælpelinjer: hjælpelinjerne danner mere end {MAX_PIECES} udsnit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 47337855aa..9295b75e68 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4264,6 +4264,10 @@ theme Appearance Erscheinungsbild @msg Couldn't place {name}: the document it was placed in was closed {name} konnte nicht platziert werden: Das Dokument, in dem die Datei platziert wurde, wurde geschlossen @msg Couldn't read {name}: {e} {name} konnte nicht gelesen werden: {e} @msg Couldn't save recovery data: {e} Wiederherstellungsdaten konnten nicht gespeichert werden: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Wiederherstellungsdaten für {title} konnten nicht gespeichert werden: {e}. Kopien werden in {folder} gespeichert, dem Standardordner für die Wiederherstellung; um einen anderen Ordner zu verwenden, legen Sie Voreinstellungen → Dateihandhabung → Datenwiederherstellung → Ordner (recoveryFolder) fest +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Wiederherstellungsdaten für {title} konnten nicht gespeichert werden: {e}. Kopien werden in {folder} gespeichert, dem in den Voreinstellungen festgelegten Ordner; um einen anderen Ordner zu verwenden, legen Sie Voreinstellungen → Dateihandhabung → Datenwiederherstellung → Ordner (recoveryFolder) fest +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Wiederherstellungsdaten konnten nicht gespeichert werden: {e}. Kopien werden in {folder} gespeichert, dem Standardordner für die Wiederherstellung; um einen anderen Ordner zu verwenden, legen Sie Voreinstellungen → Dateihandhabung → Datenwiederherstellung → Ordner (recoveryFolder) fest +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Wiederherstellungsdaten konnten nicht gespeichert werden: {e}. Kopien werden in {folder} gespeichert, dem in den Voreinstellungen festgelegten Ordner; um einen anderen Ordner zu verwenden, legen Sie Voreinstellungen → Dateihandhabung → Datenwiederherstellung → Ordner (recoveryFolder) fest @msg Create Outlines: select text objects In Pfade umwandeln: Wählen Sie Textobjekte aus @msg Create from Guides: the document has no artboard Aus Hilfslinien erstellen: Das Dokument hat keine Zeichenfläche @msg Create from Guides: the guides make more than {MAX_PIECES} slices Aus Hilfslinien erstellen: Die Hilfslinien ergeben mehr als {MAX_PIECES} Slices diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c319729a71..a6cec91dcc 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2421,6 +2421,10 @@ axis Both Και τα δύο @msg Couldn't place {name}: the document it was placed in was closed Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο τοποθετούνταν το αρχείο έκλεισε @msg Couldn't read {name}: {e} Δεν ήταν δυνατή η ανάγνωση του {name}: {e} @msg Couldn't save recovery data: {e} Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης για το {title}: {e}. Τα αντίγραφα αποθηκεύονται στο {folder}, τον προεπιλεγμένο φάκελο ανάκτησης. Για να χρησιμοποιήσετε άλλο φάκελο, ορίστε Προτιμήσεις → Χειρισμός αρχείων → Ανάκτηση δεδομένων → Φάκελος (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης για το {title}: {e}. Τα αντίγραφα αποθηκεύονται στο {folder}, τον φάκελο που ορίστηκε στις Προτιμήσεις. Για να χρησιμοποιήσετε άλλο φάκελο, ορίστε Προτιμήσεις → Χειρισμός αρχείων → Ανάκτηση δεδομένων → Φάκελος (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e}. Τα αντίγραφα αποθηκεύονται στο {folder}, τον προεπιλεγμένο φάκελο ανάκτησης. Για να χρησιμοποιήσετε άλλο φάκελο, ορίστε Προτιμήσεις → Χειρισμός αρχείων → Ανάκτηση δεδομένων → Φάκελος (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e}. Τα αντίγραφα αποθηκεύονται στο {folder}, τον φάκελο που ορίστηκε στις Προτιμήσεις. Για να χρησιμοποιήσετε άλλο φάκελο, ορίστε Προτιμήσεις → Χειρισμός αρχείων → Ανάκτηση δεδομένων → Φάκελος (recoveryFolder) @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} τομές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b203292a4..8aada37aae 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4341,6 +4341,10 @@ axis Both Ambos @msg Couldn't place {name}: the document it was placed in was closed No se pudo colocar {name}: se cerró el documento en el que se estaba colocando @msg Couldn't read {name}: {e} No se pudo leer {name}: {e} @msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No se pudieron guardar los datos de recuperación de {title}: {e}. Las copias se guardan en {folder}, la carpeta de recuperación predeterminada; para usar otra carpeta, configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No se pudieron guardar los datos de recuperación de {title}: {e}. Las copias se guardan en {folder}, la carpeta definida en Preferencias; para usar otra carpeta, configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No se pudieron guardar los datos de recuperación: {e}. Las copias se guardan en {folder}, la carpeta de recuperación predeterminada; para usar otra carpeta, configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) No se pudieron guardar los datos de recuperación: {e}. Las copias se guardan en {folder}, la carpeta definida en Preferencias; para usar otra carpeta, configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder) @msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto @msg Create from Guides: the document has no artboard Crear a partir de guías: el documento no tiene mesa de trabajo @msg Create from Guides: the guides make more than {MAX_PIECES} slices Crear a partir de guías: las guías forman más de {MAX_PIECES} sectores diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b2c6f48d54..cbf8649dcd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4565,6 +4565,10 @@ imageTrace Outline Ääriviiva @msg Couldn't place {name}: the document it was placed in was closed Kohdetta {name} ei voitu sijoittaa: asiakirja, johon sitä sijoitettiin, on suljettu @msg Couldn't read {name}: {e} Tiedostoa {name} ei voitu lukea: {e} @msg Couldn't save recovery data: {e} Palautustietoja ei voitu tallentaa: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kohteen {title} palautustietoja ei voitu tallentaa: {e}. Kopiot tallennetaan kansioon {folder}, joka on oletusarvoinen palautuskansio; jos haluat käyttää toista kansiota, määritä Asetukset → Tiedostojen käsittely → Tietojen palautus → Kansio (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kohteen {title} palautustietoja ei voitu tallentaa: {e}. Kopiot tallennetaan kansioon {folder}, joka on määritetty Asetuksissa; jos haluat käyttää toista kansiota, määritä Asetukset → Tiedostojen käsittely → Tietojen palautus → Kansio (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Palautustietoja ei voitu tallentaa: {e}. Kopiot tallennetaan kansioon {folder}, joka on oletusarvoinen palautuskansio; jos haluat käyttää toista kansiota, määritä Asetukset → Tiedostojen käsittely → Tietojen palautus → Kansio (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Palautustietoja ei voitu tallentaa: {e}. Kopiot tallennetaan kansioon {folder}, joka on määritetty Asetuksissa; jos haluat käyttää toista kansiota, määritä Asetukset → Tiedostojen käsittely → Tietojen palautus → Kansio (recoveryFolder) @msg Create Outlines: select text objects Muunna ääriviivoiksi: valitse tekstiobjekteja @msg Create from Guides: the document has no artboard Luo apuviivoista: asiakirjassa ei ole piirtoalustaa @msg Create from Guides: the guides make more than {MAX_PIECES} slices Luo apuviivoista: apuviivat muodostavat yli {MAX_PIECES} viipaletta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e84f344060..e866a1ac80 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4330,6 +4330,10 @@ axis Both Les deux @msg Couldn't place {name}: the document it was placed in was closed Impossible d’importer {name} : le document dans lequel il était importé 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 Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossible d’enregistrer les données de récupération de {title} : {e}. Les copies sont enregistrées dans {folder}, le dossier de récupération par défaut ; pour utiliser un autre dossier, définissez Préférences → Gestion des fichiers → Récupération des données → Dossier (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossible d’enregistrer les données de récupération de {title} : {e}. Les copies sont enregistrées dans {folder}, le dossier défini dans les Préférences ; pour utiliser un autre dossier, définissez Préférences → Gestion des fichiers → Récupération des données → Dossier (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossible d’enregistrer les données de récupération : {e}. Les copies sont enregistrées dans {folder}, le dossier de récupération par défaut ; pour utiliser un autre dossier, définissez Préférences → Gestion des fichiers → Récupération des données → Dossier (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossible d’enregistrer les données de récupération : {e}. Les copies sont enregistrées dans {folder}, le dossier défini dans les Préférences ; pour utiliser un autre dossier, définissez Préférences → Gestion des fichiers → Récupération des données → Dossier (recoveryFolder) @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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 017bc03750..650ac881ec 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6640,6 +6640,10 @@ antiAlias Sharp शार्प @msg Couldn't place {name}: the document it was placed in was closed नहीं था {name}: यह डॉक्यूमेंट बंद किया गया था @msg Couldn't read {name}: {e} नहीं पढ़ सकता {name}: {e} @msg Couldn't save recovery data: {e} रिकवरी डेटा सेव नहीं किया जा सका: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} का रिकवरी डेटा सेव नहीं किया जा सका: {e}। प्रतिलिपियाँ {folder} में जाती हैं, जो डिफ़ॉल्ट रिकवरी फ़ोल्डर है; कोई दूसरा फ़ोल्डर इस्तेमाल करने के लिए प्रेफ़रेंसेज़ → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) सेट करें +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} का रिकवरी डेटा सेव नहीं किया जा सका: {e}। प्रतिलिपियाँ {folder} में जाती हैं, जो प्रेफ़रेंसेज़ में सेट किया गया फ़ोल्डर है; कोई दूसरा फ़ोल्डर इस्तेमाल करने के लिए प्रेफ़रेंसेज़ → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) सेट करें +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) रिकवरी डेटा सेव नहीं किया जा सका: {e}। प्रतिलिपियाँ {folder} में जाती हैं, जो डिफ़ॉल्ट रिकवरी फ़ोल्डर है; कोई दूसरा फ़ोल्डर इस्तेमाल करने के लिए प्रेफ़रेंसेज़ → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) सेट करें +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) रिकवरी डेटा सेव नहीं किया जा सका: {e}। प्रतिलिपियाँ {folder} में जाती हैं, जो प्रेफ़रेंसेज़ में सेट किया गया फ़ोल्डर है; कोई दूसरा फ़ोल्डर इस्तेमाल करने के लिए प्रेफ़रेंसेज़ → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) सेट करें @msg counterPer `{c}`: item or file counterPer `{c}`: item या file @msg Create from Guides: the document has no artboard गाइड से बनाएँ: डॉक्यूमेंट में कोई आर्टबोर्ड नहीं है @msg Create from Guides: the guides make more than {MAX_PIECES} slices गाइड से बनाएँ: गाइड {MAX_PIECES} स्लाइस से अधिक बनाते हैं diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index effb31e5d8..b473bc0d0d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5148,6 +5148,10 @@ imageTrace Outline Körvonal @msg Couldn't place {name}: {e} Nem sikerült elhelyezni: {name}: {e} @msg Couldn't read {name}: {e} Nem sikerült beolvasni: {name}: {e} @msg Couldn't save recovery data: {e} Nem sikerült menteni a helyreállítási adatokat: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nem sikerült menteni a(z) {title} helyreállítási adatait: {e}. A másolatok helye: {folder}, az alapértelmezett helyreállítási mappa; másik mappa használatához állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nem sikerült menteni a(z) {title} helyreállítási adatait: {e}. A másolatok helye: {folder}, a Beállításokban megadott mappa; másik mappa használatához állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nem sikerült menteni a helyreállítási adatokat: {e}. A másolatok helye: {folder}, az alapértelmezett helyreállítási mappa; másik mappa használatához állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nem sikerült menteni a helyreállítási adatokat: {e}. A másolatok helye: {folder}, a Beállításokban megadott mappa; másik mappa használatához állítsd be a Beállítások → Fájlkezelés → Adat-helyreállítás → Mappa értéket (recoveryFolder) @msg Create from Guides: the document has no artboard Létrehozás segédvonalakból: a dokumentumban nincs rajztábla @msg Create from Guides: the guides make more than {MAX_PIECES} slices Létrehozás segédvonalakból: a segédvonalak túl sok szeletet adnak (legfeljebb {MAX_PIECES}) @msg Create from Selection: the selection has no area Létrehozás a kijelölésből: a kijelölésnek nincs területe diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 912c6954eb..e03ed65a11 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4248,6 +4248,10 @@ imageTrace Outline Kontur @msg Couldn't place {name}: the document it was placed in was closed Tidak dapat menempatkan {name}: dokumen tempatnya ditempatkan sudah ditutup @msg Couldn't read {name}: {e} Tidak dapat membaca {name}: {e} @msg Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Tidak dapat menyimpan data pemulihan untuk {title}: {e}. Salinan disimpan di {folder}, folder pemulihan bawaan; untuk memakai folder lain, atur Preferensi → Penanganan File → Pemulihan Data → Folder (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Tidak dapat menyimpan data pemulihan untuk {title}: {e}. Salinan disimpan di {folder}, folder yang diatur di Preferensi; untuk memakai folder lain, atur Preferensi → Penanganan File → Pemulihan Data → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Tidak dapat menyimpan data pemulihan: {e}. Salinan disimpan di {folder}, folder pemulihan bawaan; untuk memakai folder lain, atur Preferensi → Penanganan File → Pemulihan Data → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Tidak dapat menyimpan data pemulihan: {e}. Salinan disimpan di {folder}, folder yang diatur di Preferensi; untuk memakai folder lain, atur Preferensi → Penanganan File → Pemulihan Data → Folder (recoveryFolder) @msg Create Outlines: select text objects Buat kontur: pilih objek teks @msg Create from Guides: the document has no artboard Buat dari panduan: dokumen tidak memiliki artboard @msg Create from Guides: the guides make more than {MAX_PIECES} slices Buat dari panduan: panduan menghasilkan lebih dari {MAX_PIECES} irisan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2d6c7e2186..daf7e54cff 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4393,6 +4393,10 @@ axis Both Entrambi @msg Couldn't place {name}: the document it was placed in was closed Impossibile inserire {name}: il documento in cui veniva inserito è stato chiuso @msg Couldn't read {name}: {e} Impossibile leggere {name}: {e} @msg Couldn't save recovery data: {e} Impossibile salvare i dati di recupero: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossibile salvare i dati di recupero di {title}: {e}. Le copie vanno in {folder}, la cartella di recupero predefinita; per usare un’altra cartella, imposta Preferenze → Gestione file → Recupero dati → Cartella (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossibile salvare i dati di recupero di {title}: {e}. Le copie vanno in {folder}, la cartella impostata nelle Preferenze; per usare un’altra cartella, imposta Preferenze → Gestione file → Recupero dati → Cartella (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossibile salvare i dati di recupero: {e}. Le copie vanno in {folder}, la cartella di recupero predefinita; per usare un’altra cartella, imposta Preferenze → Gestione file → Recupero dati → Cartella (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Impossibile salvare i dati di recupero: {e}. Le copie vanno in {folder}, la cartella impostata nelle Preferenze; per usare un’altra cartella, imposta Preferenze → Gestione file → Recupero dati → Cartella (recoveryFolder) @msg Create Outlines: select text objects Crea contorni: seleziona oggetti di testo @msg Create from Guides: the document has no artboard Crea da guide: il documento non ha una tavola da disegno @msg Create from Guides: the guides make more than {MAX_PIECES} slices Crea da guide: le guide generano più di {MAX_PIECES} sezioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 17444d894e..53745f6f9f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5188,6 +5188,10 @@ swatch Bright Violet ビビッドバイオレット @msg the print job has no data プリントジョブにデータがありません @msg there are no printer settings to open here この環境では開けるプリンター設定がありません @msg Couldn't save recovery data: {e} 復帰用データを保存できませんでした: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} の復帰用データを保存できませんでした: {e}。コピーの保存先は {folder} (デフォルトの復帰用フォルダー) です。別のフォルダーを使うには、「環境設定 → ファイル管理 → データ復帰 → フォルダー」 (recoveryFolder) を設定してください +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} の復帰用データを保存できませんでした: {e}。コピーの保存先は {folder} (環境設定で指定したフォルダー) です。別のフォルダーを使うには、「環境設定 → ファイル管理 → データ復帰 → フォルダー」 (recoveryFolder) を設定してください +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 復帰用データを保存できませんでした: {e}。コピーの保存先は {folder} (デフォルトの復帰用フォルダー) です。別のフォルダーを使うには、「環境設定 → ファイル管理 → データ復帰 → フォルダー」 (recoveryFolder) を設定してください +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 復帰用データを保存できませんでした: {e}。コピーの保存先は {folder} (環境設定で指定したフォルダー) です。別のフォルダーを使うには、「環境設定 → ファイル管理 → データ復帰 → フォルダー」 (recoveryFolder) を設定してください @msg Saved recovery data for {title} {title} の復帰用データを保存しました @msgplural Discarded {n} recovered document|Discarded {n} recovered documents 復帰用データのドキュメントを {n} 件破棄しました @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them 復帰用データからドキュメントを {n} 件復元しました: 残すには保存してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be8a847cb2..a962b7d21a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4047,6 +4047,10 @@ font-technology Variable ცვლადი @msg Couldn't place {name}: the document it was placed in was closed {name} ვერ განთავსდა: დოკუმენტი, რომელშიც ის თავსდებოდა, დაიხურა @msg Couldn't read {name}: {e} {name} ვერ წაიკითხა: {e} @msg Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) @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}-ზე მეტ ნაჭერს ქმნის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 991daba6d0..e545ee515e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5115,6 +5115,10 @@ theme Appearance 외관 @msg the print job has no data 인쇄 작업에 데이터가 없습니다 @msg there are no printer settings to open here 여기에는 열 수 있는 프린터 설정이 없습니다 @msg Couldn't save recovery data: {e} 복구 데이터를 저장할 수 없습니다: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title}의 복구 데이터를 저장할 수 없습니다: {e}. 복사본은 기본 복구 폴더인 {folder}에 저장됩니다. 다른 폴더를 사용하려면 환경 설정 → 파일 처리 → 데이터 복구 → 폴더(recoveryFolder)를 지정하세요 +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title}의 복구 데이터를 저장할 수 없습니다: {e}. 복사본은 환경 설정에서 지정한 폴더인 {folder}에 저장됩니다. 다른 폴더를 사용하려면 환경 설정 → 파일 처리 → 데이터 복구 → 폴더(recoveryFolder)를 지정하세요 +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 복구 데이터를 저장할 수 없습니다: {e}. 복사본은 기본 복구 폴더인 {folder}에 저장됩니다. 다른 폴더를 사용하려면 환경 설정 → 파일 처리 → 데이터 복구 → 폴더(recoveryFolder)를 지정하세요 +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 복구 데이터를 저장할 수 없습니다: {e}. 복사본은 환경 설정에서 지정한 폴더인 {folder}에 저장됩니다. 다른 폴더를 사용하려면 환경 설정 → 파일 처리 → 데이터 복구 → 폴더(recoveryFolder)를 지정하세요 @msg Saved recovery data for {title} {title}의 복구 데이터를 저장했습니다 @msgplural Discarded {n} recovered document|Discarded {n} recovered documents 복구된 문서 {n}개를 삭제했습니다 @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them 복구된 문서 {n}개를 복원했습니다: 보관하려면 저장하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b8ca33858f..9ef7d14b40 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4763,6 +4763,10 @@ imageTrace Outline Contour @msg Couldn't place {name}: the document it was placed in was closed Kon {name} niet plaatsen: het document waarin het werd geplaatst, is gesloten @msg Couldn't read {name}: {e} Kon {name} niet lezen: {e} @msg Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kon herstelgegevens voor {title} niet opslaan: {e}. Kopieën gaan naar {folder}, de standaardherstelmap; stel voor een andere map Voorkeuren → Bestandsafhandeling → Gegevensherstel → Map (recoveryFolder) in +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kon herstelgegevens voor {title} niet opslaan: {e}. Kopieën gaan naar {folder}, de map die in Voorkeuren is ingesteld; stel voor een andere map Voorkeuren → Bestandsafhandeling → Gegevensherstel → Map (recoveryFolder) in +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kon herstelgegevens niet opslaan: {e}. Kopieën gaan naar {folder}, de standaardherstelmap; stel voor een andere map Voorkeuren → Bestandsafhandeling → Gegevensherstel → Map (recoveryFolder) in +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kon herstelgegevens niet opslaan: {e}. Kopieën gaan naar {folder}, de map die in Voorkeuren is ingesteld; stel voor een andere map Voorkeuren → Bestandsafhandeling → Gegevensherstel → Map (recoveryFolder) in @msg Create Outlines: select text objects Contouren maken: selecteer tekstobjecten @msg Create from Guides: the document has no artboard Uit hulplijnen maken: het document heeft geen tekengebied @msg Create from Guides: the guides make more than {MAX_PIECES} slices Uit hulplijnen maken: de hulplijnen maken meer dan {MAX_PIECES} slices diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eb5afe87cc..2520ea9318 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4475,6 +4475,10 @@ imageTrace Outline Kontur @msg Couldn't place {name}: the document it was placed in was closed Nie udało się umieścić {name}: dokument, w którym był umieszczany, został zamknięty @msg Couldn't read {name}: {e} Nie udało się odczytać {name}: {e} @msg Couldn't save recovery data: {e} Nie udało się zapisać danych odzyskiwania: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nie udało się zapisać danych odzyskiwania dla {title}: {e}. Kopie trafiają do {folder}, domyślnego folderu odzyskiwania; aby użyć innego folderu, ustaw Preferencje → Obsługa plików → Odzyskiwanie danych → Folder (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nie udało się zapisać danych odzyskiwania dla {title}: {e}. Kopie trafiają do {folder}, folderu ustawionego w Preferencjach; aby użyć innego folderu, ustaw Preferencje → Obsługa plików → Odzyskiwanie danych → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nie udało się zapisać danych odzyskiwania: {e}. Kopie trafiają do {folder}, domyślnego folderu odzyskiwania; aby użyć innego folderu, ustaw Preferencje → Obsługa plików → Odzyskiwanie danych → Folder (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Nie udało się zapisać danych odzyskiwania: {e}. Kopie trafiają do {folder}, folderu ustawionego w Preferencjach; aby użyć innego folderu, ustaw Preferencje → Obsługa plików → Odzyskiwanie danych → Folder (recoveryFolder) @msg Create Outlines: select text objects Utwórz kontury: zaznacz obiekty tekstowe @msg Create from Guides: the document has no artboard Utwórz z linii pomocniczych: dokument nie ma obszaru kompozycji @msg Create from Guides: the guides make more than {MAX_PIECES} slices Utwórz z linii pomocniczych: linie pomocnicze tworzą więcej niż {MAX_PIECES} plasterków diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80615d6370..ac7247dee1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3810,6 +3810,10 @@ axis Both Ambos @msg Couldn't place {name}: the document it was placed in was closed Não foi possível inserir {name}: o documento em que estava sendo inserido foi fechado @msg Couldn't read {name}: {e} Não foi possível ler {name}: {e} @msg Couldn't save recovery data: {e} Não foi possível salvar os dados de recuperação: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Não foi possível salvar os dados de recuperação de {title}: {e}. As cópias vão para {folder}, a pasta de recuperação padrão; para usar outra pasta, defina Preferências → Manipulação de arquivos → Recuperação de dados → Pasta (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Não foi possível salvar os dados de recuperação de {title}: {e}. As cópias vão para {folder}, a pasta definida em Preferências; para usar outra pasta, defina Preferências → Manipulação de arquivos → Recuperação de dados → Pasta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Não foi possível salvar os dados de recuperação: {e}. As cópias vão para {folder}, a pasta de recuperação padrão; para usar outra pasta, defina Preferências → Manipulação de arquivos → Recuperação de dados → Pasta (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Não foi possível salvar os dados de recuperação: {e}. As cópias vão para {folder}, a pasta definida em Preferências; para usar outra pasta, defina Preferências → Manipulação de arquivos → Recuperação de dados → Pasta (recoveryFolder) @msg Create Outlines: select text objects Criar contornos: selecione objetos de texto @msg Create from Guides: the document has no artboard Criar a partir de guias: o documento não tem prancheta @msg Create from Guides: the guides make more than {MAX_PIECES} slices Criar a partir de guias: as guias formam mais de {MAX_PIECES} fatias diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c830e69b37..684dbda556 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4378,6 +4378,10 @@ axis Both Оба @msg Couldn't place {name}: the document it was placed in was closed Не удалось поместить {name}: документ, в который его помещали, был закрыт @msg Couldn't read {name}: {e} Не удалось прочитать {name}: {e} @msg Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не удалось сохранить данные восстановления для {title}: {e}. Копии сохраняются в {folder}, папку восстановления по умолчанию; чтобы использовать другую папку, задайте Настройки → Работа с файлами → Восстановление данных → Папка (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не удалось сохранить данные восстановления для {title}: {e}. Копии сохраняются в {folder}, папку, заданную в настройках; чтобы использовать другую папку, задайте Настройки → Работа с файлами → Восстановление данных → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не удалось сохранить данные восстановления: {e}. Копии сохраняются в {folder}, папку восстановления по умолчанию; чтобы использовать другую папку, задайте Настройки → Работа с файлами → Восстановление данных → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не удалось сохранить данные восстановления: {e}. Копии сохраняются в {folder}, папку, заданную в настройках; чтобы использовать другую папку, задайте Настройки → Работа с файлами → Восстановление данных → Папка (recoveryFolder) @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} фрагментов diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d4304b97bd..2acfabc6f8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4251,6 +4251,10 @@ axis Both Båda @msg Couldn't place {name}: the document it was placed in was closed Kunde inte placera {name}: dokumentet det placerades i har stängts @msg Couldn't read {name}: {e} Kunde inte läsa {name}: {e} @msg Couldn't save recovery data: {e} Kunde inte spara återställningsdata: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunde inte spara återställningsdata för {title}: {e}. Kopiorna sparas i {folder}, standardmappen för återställning; om du vill använda en annan mapp anger du Inställningar → Filhantering → Dataåterställning → Mapp (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunde inte spara återställningsdata för {title}: {e}. Kopiorna sparas i {folder}, mappen som angetts i Inställningar; om du vill använda en annan mapp anger du Inställningar → Filhantering → Dataåterställning → Mapp (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunde inte spara återställningsdata: {e}. Kopiorna sparas i {folder}, standardmappen för återställning; om du vill använda en annan mapp anger du Inställningar → Filhantering → Dataåterställning → Mapp (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kunde inte spara återställningsdata: {e}. Kopiorna sparas i {folder}, mappen som angetts i Inställningar; om du vill använda en annan mapp anger du Inställningar → Filhantering → Dataåterställning → Mapp (recoveryFolder) @msg Create Outlines: select text objects Skapa konturer: markera textobjekt @msg Create from Guides: the document has no artboard Skapa från stödlinjer: dokumentet har ingen rityta @msg Create from Guides: the guides make more than {MAX_PIECES} slices Skapa från stödlinjer: stödlinjerna ger fler än {MAX_PIECES} skivor diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5fc694ec2..ca88601cac 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4131,6 +4131,10 @@ axis Both Her İkisi @msg Couldn't place {name}: the document it was placed in was closed {name} yerleştirilemedi: yerleştirildiği belge kapatıldı @msg Couldn't read {name}: {e} {name} okunamadı: {e} @msg Couldn't save recovery data: {e} Kurtarma verileri kaydedilemedi: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} için kurtarma verileri kaydedilemedi: {e}. Kopyalar varsayılan kurtarma klasörüne, {folder} konumuna kaydedilir; başka bir klasör kullanmak için Tercihler → Dosya İşleme → Veri Kurtarma → Klasör (recoveryFolder) ayarını yapın +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) {title} için kurtarma verileri kaydedilemedi: {e}. Kopyalar Tercihler'de ayarlanan klasöre, {folder} konumuna kaydedilir; başka bir klasör kullanmak için Tercihler → Dosya İşleme → Veri Kurtarma → Klasör (recoveryFolder) ayarını yapın +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kurtarma verileri kaydedilemedi: {e}. Kopyalar varsayılan kurtarma klasörüne, {folder} konumuna kaydedilir; başka bir klasör kullanmak için Tercihler → Dosya İşleme → Veri Kurtarma → Klasör (recoveryFolder) ayarını yapın +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Kurtarma verileri kaydedilemedi: {e}. Kopyalar Tercihler'de ayarlanan klasöre, {folder} konumuna kaydedilir; başka bir klasör kullanmak için Tercihler → Dosya İşleme → Veri Kurtarma → Klasör (recoveryFolder) ayarını yapın @msg Create Outlines: select text objects Anahatlar Oluştur: metin nesnelerini seçin @msg Create from Guides: the document has no artboard Kılavuzlardan Oluştur: belgede çalışma yüzeyi yok @msg Create from Guides: the guides make more than {MAX_PIECES} slices Kılavuzlardan Oluşturun: kılavuzlar {MAX_PIECES} dilimlerinden daha fazlasını oluşturur diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a36512db0..0c6f6be88d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4377,6 +4377,10 @@ axis Both Обидві @msg Couldn't place {name}: the document it was placed in was closed Не вдалося помістити {name}: документ, у який його поміщали, було закрито @msg Couldn't read {name}: {e} Неможливо прочитати {name}: {e} @msg Couldn't save recovery data: {e} Не вдалося зберегти дані відновлення: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не вдалося зберегти дані відновлення для {title}: {e}. Копії зберігаються в {folder}, типовій папці відновлення; щоб використати іншу папку, задайте Установки → Робота з файлами → Відновлення даних → Папка (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не вдалося зберегти дані відновлення для {title}: {e}. Копії зберігаються в {folder}, папці, заданій в установках; щоб використати іншу папку, задайте Установки → Робота з файлами → Відновлення даних → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не вдалося зберегти дані відновлення: {e}. Копії зберігаються в {folder}, типовій папці відновлення; щоб використати іншу папку, задайте Установки → Робота з файлами → Відновлення даних → Папка (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Не вдалося зберегти дані відновлення: {e}. Копії зберігаються в {folder}, папці, заданій в установках; щоб використати іншу папку, задайте Установки → Робота з файлами → Відновлення даних → Папка (recoveryFolder) @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} фрагментів diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 839f5c7405..7eae5e64b1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4806,6 +4806,10 @@ theme Appearance Giao diện @msg Couldn't place {name}: {e} Không thể đặt {name}: {e} @msg Couldn't read {name}: {e} Không thể đọc {name}: {e} @msg Couldn't save recovery data: {e} Không thể lưu dữ liệu phục hồi: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Không thể lưu dữ liệu phục hồi cho {title}: {e}. Bản sao được lưu vào {folder}, thư mục phục hồi mặc định; để dùng thư mục khác, hãy đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Không thể lưu dữ liệu phục hồi cho {title}: {e}. Bản sao được lưu vào {folder}, thư mục được đặt trong Tùy chọn; để dùng thư mục khác, hãy đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Không thể lưu dữ liệu phục hồi: {e}. Bản sao được lưu vào {folder}, thư mục phục hồi mặc định; để dùng thư mục khác, hãy đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Không thể lưu dữ liệu phục hồi: {e}. Bản sao được lưu vào {folder}, thư mục được đặt trong Tùy chọn; để dùng thư mục khác, hãy đặt Tùy chọn → Xử lý tệp → Phục hồi dữ liệu → Thư mục (recoveryFolder) @msg Create from Guides: the document has no artboard Tạo từ đường căn: tài liệu không có bảng vẽ @msg Create from Guides: the guides make more than {MAX_PIECES} slices Tạo từ đường căn: các đường căn tạo ra hơn {MAX_PIECES} lát cắt @msg Create from Selection: the selection has no area Tạo từ vùng chọn: vùng chọn không có diện tích diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b0d6001e32..cfde6d09e8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4871,6 +4871,10 @@ imageTrace Outline 轮廓 @msg the print job has no data 打印作业没有数据 @msg there are no printer settings to open here 此处没有可打开的打印机设置 @msg Couldn't save recovery data: {e} 无法保存恢复数据:{e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 无法保存 {title} 的恢复数据:{e}。副本保存在 {folder}(默认恢复文件夹);要使用其他文件夹,请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 无法保存 {title} 的恢复数据:{e}。副本保存在 {folder}(在“首选项”中设置的文件夹);要使用其他文件夹,请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 无法保存恢复数据:{e}。副本保存在 {folder}(默认恢复文件夹);要使用其他文件夹,请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 无法保存恢复数据:{e}。副本保存在 {folder}(在“首选项”中设置的文件夹);要使用其他文件夹,请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) @msg Saved recovery data for {title} 已保存 {title} 的恢复数据 @msgplural Discarded {n} recovered document|Discarded {n} recovered documents 已丢弃 {n} 个恢复的文档 @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them 已还原 {n} 个恢复的文档:请保存以保留它们 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c24ae37609..5ddffc1733 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2620,6 +2620,10 @@ file-filter Templates 範本 @msg the print job has no data 列印工作沒有資料 @msg there are no printer settings to open here 此處沒有可開啟的印表機設定 @msg Couldn't save recovery data: {e} 無法儲存復原資料:{e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 無法儲存 {title} 的復原資料:{e}。拷貝存放在 {folder}(預設的復原資料夾);若要使用其他資料夾,請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 無法儲存 {title} 的復原資料:{e}。拷貝存放在 {folder}(在「偏好設定」中設定的資料夾);若要使用其他資料夾,請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 無法儲存復原資料:{e}。拷貝存放在 {folder}(預設的復原資料夾);若要使用其他資料夾,請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 無法儲存復原資料:{e}。拷貝存放在 {folder}(在「偏好設定」中設定的資料夾);若要使用其他資料夾,請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder) @msg Saved recovery data for {title} 已儲存 {title} 的復原資料 @msgplural Discarded {n} recovered document|Discarded {n} recovered documents 已捨棄 {n} 份復原的文件 @msgplural Restored {n} recovered document: save it to keep it|Restored {n} recovered documents: save them to keep them 已還原 {n} 份復原的文件:請儲存以保留 diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index f6188babca..50bb043b36 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -26,8 +26,14 @@ pub struct Timer { last: Option<f64>, /// When the heartbeat was last refreshed (app time, s). beat: f64, + /// The recovery folders that failure messages named, latest last, at most [`NAMED`]: replies to + /// automation clients hide those outside their read roots. + pub named: Vec<String>, } +/// How many recovery folders [`Timer::named`] keeps. +const NAMED: usize = 8; + /// Each frame: the startup question once no other dialog is open, the timer and the heartbeat /// (`now`: app time in seconds) → in how many seconds they want the next frame (none while Data Recovery is off). pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option<f64> { @@ -94,34 +100,92 @@ pub fn tick(app: &mut VectorcraftApp, now: f64) -> usize { /// were started. fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn(u64) -> bool) -> usize { recovery::forget_clean(&mut app.session); + // The folder `store` writes to, named in a failure even when the preference changes meanwhile. + let place = recovery::folder(&app.session); let jobs = match recovery::jobs(&mut app.session, store) { // A document whose save or last copy is still being written waits for the next round. Ok((jobs, _)) => jobs.into_iter().filter(|j| pick(j.uid) && !app.background.busy_with(j.uid)).collect::<Vec<_>>(), Err(e) => { - app.status(format!("Couldn't save recovery data: {e}")); + failed(app, place.as_ref(), &e.to_string()); return 0; } }; let mut started = 0; for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); - let done = job.clone(); + let (done, at, saving) = (job.clone(), place.clone(), label.clone()); let work = move |_: Writer| job.write(); - let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| { - let r = r?; - done.finish(&mut app.session); - app.status(format!("Saved recovery data for {title}")); - Ok(r) + // A failure is reported here, so the job's result is not an error: that would show as + // "<label> failed: <error>" over this line, or as the round's message when it ran here. + let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| match (r, at) { + (Ok(r), _) => { + done.finish(&mut app.session); + app.status(format!("Saved recovery data for {title}")); + Ok(r) + } + (Err(e), Some(at)) => { + copy_failed(app, &title, &at, &e); + Ok(Value::Null) + } + // A store the host installed has no folder to name: the job's own line. + (Err(e), None) => { + app.status(format!("{saving} failed: {e}")); + log::warn!("vectorcraft: {}", app.ui.status); + Ok(Value::Null) + } }; // Its own store, not the file writer: this only decides whether it runs on the worker. if let Err(e) = background::run(app, true, label, Some(uid), work, then) { - app.status(format!("Couldn't save recovery data: {e}")); + failed(app, place.as_ref(), &e); } started += 1; } started } +/// Report a recovery round that couldn't start (error `e`) in the status bar and the log, with +/// the recovery folder in use (`place`, from [`recovery::folder`]) and whether the preference or +/// the app set it. +fn failed(app: &mut VectorcraftApp, place: Option<&(String, bool)>, e: &str) { + match place { + Some((folder, true)) => app.status(format!( + "Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" + )), + Some((folder, false)) => app.status(format!( + "Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" + )), + None => app.status(format!("Couldn't save recovery data: {e}")), + } + if let Some((folder, _)) = place { + named(app, folder); + } + log::warn!("vectorcraft: {}", app.ui.status); +} + +/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`. +fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str) { + match place { + (folder, true) => app.status(format!( + "Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" + )), + (folder, false) => app.status(format!( + "Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" + )), + } + named(app, &place.0); + log::warn!("vectorcraft: {}", app.ui.status); +} + +/// Record that the status text named recovery folder `folder` ([`Timer::named`]). +fn named(app: &mut VectorcraftApp, folder: &str) { + let named = &mut app.recovery.named; + named.retain(|f| f != folder); + named.push(folder.to_string()); + if named.len() > NAMED { + named.remove(0); + } +} + /// The startup dialog's OK (Restore) or Discard: every copy it lists. pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> { app.ui.dialog = None; diff --git a/crates/ui-egui/src/tests/recovery.rs b/crates/ui-egui/src/tests/recovery.rs index 806903148f..00ccb59b79 100644 --- a/crates/ui-egui/src/tests/recovery.rs +++ b/crates/ui-egui/src/tests/recovery.rs @@ -177,6 +177,131 @@ fn the_timer_asks_for_frames_while_idle() { assert!(recovery::frame(&mut app, 1.0).is_none()); } +/// The end of a failed recovery round's status line: how to use another folder. +const TO_DO: &str = "; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)"; + +/// An app keeping its copies in `folder` (the folder the app sets) or the one the preference +/// names, with a modified document, a recovery round every 2 minutes and the clock started. +fn app_in_folder(folder: &str, services: Services) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), services); + app.session.recovery.set_default_folder(Some(folder.to_string())); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + app.session.prefs.autosave_interval = 2; + recovery::tick(&mut app, 0.0); + app +} + +/// A fresh empty folder for test `name`. +fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-ui-recovery-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A recovery round that can't start names the recovery folder in use, whether the preference or +/// the app set it, the error, and what to do. +#[test] +fn a_round_that_fails_names_the_folder_in_use_and_what_to_do() { + let dir = scratch("round"); + let file = dir.join("not-a-folder"); + std::fs::write(&file, b"").unwrap(); + let shown = file.to_string_lossy().into_owned(); + let mut app = app_in_folder(&shown, Services::default()); + assert_eq!(recovery::tick(&mut app, 120.0), 0); + let start = format!("Couldn't save recovery data: {shown} is not a folder: "); + let end = format!(". Copies go to {shown}, the default recovery folder{TO_DO}"); + assert!(app.ui.status.starts_with(&start) && app.ui.status.ends_with(&end), "{}", app.ui.status); + // A preference of spaces is no preference. + app.session.prefs.recovery_folder = " ".into(); + assert_eq!(recovery::tick(&mut app, 240.0), 0); + assert!(app.ui.status.starts_with(&start) && app.ui.status.ends_with(&end), "{}", app.ui.status); + app.session.prefs.recovery_folder = format!(" {shown} "); + assert_eq!(recovery::tick(&mut app, 360.0), 0); + let end = format!(". Copies go to {shown}, the folder set in Preferences{TO_DO}"); + assert!(app.ui.status.starts_with(&start) && app.ui.status.ends_with(&end), "{}", app.ui.status); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy that fails on the background worker is reported the same way, not as the job's own +/// "Saving recovery data for … failed" line. +#[test] +fn a_copy_that_fails_in_the_background_names_the_folder_and_what_to_do() { + let dir = scratch("copy"); + let folder = dir.join("recovery"); + let shown = folder.to_string_lossy().into_owned(); + let services = Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let mut app = app_in_folder(&shown, services); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + assert_eq!(app.background.jobs.len(), 1, "on the worker"); + background::wait_all(&mut app); + assert_eq!(app.ui.status, "Saved recovery data for Untitled-1"); + // A folder with a file in it takes the copy's place, so the next copy can't be written. + let name = app.session.doc().unwrap().recovery.as_ref().unwrap().file.clone(); + let copy = folder.join(format!("{name}.vectorcraft")); + std::fs::remove_file(©).unwrap(); + std::fs::create_dir(©).unwrap(); + std::fs::write(copy.join("in-the-way"), b"").unwrap(); + app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(recovery::tick(&mut app, 240.0), 1); + // A folder chosen while the copy runs isn't the one that failed. + app.session.prefs.recovery_folder = dir.join("chosen-later").to_string_lossy().into_owned(); + background::wait_all(&mut app); + let end = format!(". Copies go to {shown}, the default recovery folder{TO_DO}"); + assert!(app.ui.status.starts_with("Couldn't save recovery data for Untitled-1: ") && app.ui.status.ends_with(&end), "{}", app.ui.status); + // Kept for replies to automation clients, which hide it outside their read roots. + assert_eq!(app.recovery.named, std::slice::from_ref(&shown)); + drop(app); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A store the host installed whose document copies can't be written (other entries, such as +/// locks, can). +struct Refusing(MemoryStore); + +impl RecoveryStore for Refusing { + fn list(&self) -> Result<Vec<String>, String> { + self.0.list() + } + fn read(&self, name: &str) -> Result<Vec<u8>, String> { + self.0.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + if name.ends_with(".vectorcraft") { Err("the store is full".into()) } else { self.0.write(name, bytes) } + } + fn remove(&self, name: &str) -> Result<(), String> { + self.0.remove(name) + } + fn location(&self) -> String { + self.0.location() + } + fn lock(&self, area: &str) -> Result<vectorcraft_engine::cmd::recovery::Lock, String> { + self.0.lock(area) + } + fn now(&self) -> Option<i64> { + self.0.now() + } +} + +/// With a store the host installed there is no folder to name: a copy that fails shows the job's +/// own line with the document's title, both on the worker and when it is written right away. +#[test] +fn a_copy_that_fails_in_a_host_store_names_the_document() { + let worker = Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + for services in [worker, Services::default()] { + let store: Arc<dyn RecoveryStore> = Arc::new(Refusing(MemoryStore::default())); + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store), ..services }); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + app.session.prefs.autosave_interval = 2; + recovery::tick(&mut app, 0.0); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + background::wait_all(&mut app); + assert_eq!(app.ui.status, "Saving recovery data for Untitled-1 failed: the store is full"); + } +} + /// Issue #367: a tab whose timers were paused (in the background) long enough for another tab to /// take it for gone and discard its copy writes the copy again on its first heartbeat after it /// resumes, even with the timer off (the copy was written by hand). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..ba48b80563 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1540,6 +1540,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `.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). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. `prefs.set` applies the dialog's check to `recoveryFolder` and `pluginsFolder` for every caller (VectorCraft's own rule): a folder that fails returns an error and no preference changes; an empty value and the folder already saved are not checked. + When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`). Export As, Save as PDF, Save for Office Documents, Save for Web, Text Export Options, print to file, the presets' Export… and the library, swatch and Document Info saves replace a typed extension of another format than the one they write in the same way: Export As PNG writes `card.svg` as `card.png`, Save as PDF writes `card.png` as `card.pdf`, and `document.exportSelection` with a `format` suggests and adds that format's extension. An extension of the written format stays as typed in any letter case (`photo.JPEG` for JPEG), Export As SVG writes `art.svgz` compressed, and `document.exportSelection` without a `format` writes the format the typed extension names (`tests/saveext.rs`). From 55f43d2acb31f868b15c516c92c1229afa285158 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 12:54:12 -0800 Subject: [PATCH 20/27] PDF export: a warped image writes its pixels once for all its pieces With downsampling on, each piece of an image bent by a Warp effect was resampled for its own placed size, so a warp wrote one image XObject for each size a piece was placed at (four for a 4 x 4 warp that stretches across, eight for a colorized one with its soft masks). The pieces are now resampled for the widest and the highest size any piece places the image at, and all paint that one image. Pieces off the page count too, and another placement of the same image keeps its own size. Without downsampling, a colorized image and a CMYK image with a grid mask no longer key their cache by placed size, so each is decoded and encoded once for all the sizes it is placed at, as plain images are. --- crates/pdf/src/export.rs | 3 + crates/pdf/src/export/exporter.rs | 1 + crates/pdf/src/export/images.rs | 111 +++++++++++++++++++++++++--- crates/pdf/src/export/nodes.rs | 13 +++- crates/pdf/src/tests/colorize.rs | 33 +++++++++ crates/pdf/src/tests/compression.rs | 52 ++++++++++++- docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 8 files changed, 204 insertions(+), 12 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f4d65398e0..4ba4aa123d 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> { images: HashMap<String, Option<Image>>, /// `Some` while opacity mask art is written: whether an image in it was left out. image_left_out: Option<bool>, + /// While the pieces of a mesh-warped image are written: the size its pixels are resampled for + /// ([`Exporter::warp_image_size`]). + warp_size: Option<images::SharedSize>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option<Vec<vectorcraft_brush::Brush>>, /// Whether the group being written is a knockout group (what its neutral children inherit). diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index ff94c6759f..22109b3e82 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -31,6 +31,7 @@ impl<'a> Exporter<'a> { warnings: vec![], images: HashMap::new(), image_left_out: None, + warp_size: None, brushes: None, knockout: doc.page_knockout, lab_spots: vec![], diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index c377b9f7e4..d0158d8cd2 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -1,5 +1,5 @@ use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf}; -use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla}; +use super::{Arc, BlendMode, Color, Exporter, Image, Node, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla}; use krilla::color::device_n::{Colorant, DeviceNSpace, Process}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; @@ -59,13 +59,17 @@ impl Exporter<'_> { /// the Output settings write them. The coverage is resampled as the Compression settings ask /// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color, the image is /// missing or left out, its pixels can't be decoded, or its color channel exceeds - /// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; image rejections are cached like [`Self::load_image`]. + /// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached by key and paint (and size when images are + /// resampled), image rejections too. /// With `interpolate` (and the export's own setting), viewers may smooth it. pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> { let Paint::Solid { color, swatch, tint } = paint else { return None }; - let size = placed_size(im); + let size = self.image_size(im); let interpolate = interpolate && self.interpolate; - let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1); + let mut cache = format!("{}\u{0}{paint:?}", im.key); + if self.downsampled() { + cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1)); + } if !interpolate && self.interpolate { cache.push_str("\u{0}pixelated"); } @@ -124,9 +128,8 @@ impl Exporter<'_> { fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option<Image> { let key = im.key.as_str(); let c = self.compression; - let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); let interpolate = interpolate && self.interpolate; - let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + let mut cache = if self.downsampled() { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; if !interpolate && self.interpolate { cache.push_str("\u{0}pixelated"); } @@ -173,9 +176,12 @@ impl Exporter<'_> { if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { return None; } - let size = placed_size(im); + let size = self.image_size(im); let interpolate = interpolate && self.interpolate; - let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1); + let mut cache = format!("{}\u{0}{}\u{0}{}", im.key, mi.key, m.clip); + if self.downsampled() { + cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1)); + } if !interpolate && self.interpolate { cache.push_str("\u{0}pixelated"); } @@ -206,7 +212,7 @@ impl Exporter<'_> { } return; } - let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else { + let Some(img) = img.or_else(|| self.load_image(im, self.image_size(im), !pixelated)) else { self.image_left_out = self.image_left_out.map(|_| true); return; }; @@ -221,6 +227,56 @@ impl Exporter<'_> { s.draw_image(img, size); s.pop(); } + + /// Whether the Compression settings resample any kind of image. + fn downsampled(&self) -> bool { + let c = self.compression; + [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None) + } + + /// The size image `im` is resampled for: [`Self::warp_image_size`]'s while the pieces of a + /// mesh-warped image of the same pixels are written, else the size it is placed at. + fn image_size(&self, im: &vectorcraft_doc::ImageObject) -> (f64, f64) { + match &self.warp_size { + Some(w) if (w.key.as_str(), w.width, w.height) == (im.key.as_str(), im.width, im.height) => w.size, + _ => placed_size(im), + } + } + + /// When `children` are the pieces of a mesh-warped image ([`Node::warp_piece_image`]) and the + /// Compression settings resample images: the image they show, and the widest and highest any + /// piece places it, which every piece's image is resampled for, so that the pieces paint one + /// image. `None` for other children, pieces of different images, or a size that isn't finite + /// and positive. + pub(super) fn warp_image_size(&self, children: &[Arc<Node>]) -> Option<SharedSize> { + if !self.downsampled() { + return None; + } + let mut shared: Option<SharedSize> = None; + for c in children { + let n = c.warp_piece_image()?; + let NodeKind::Image(im) = &n.kind else { return None }; + let im = self.doc.shown_image(n, im); + let (w, h) = placed_size(&im); + match &mut shared { + Some(s) if (s.key.as_str(), s.width, s.height) == (im.key.as_str(), im.width, im.height) => { + s.size = (s.size.0.max(w), s.size.1.max(h)); + } + Some(_) => return None, + None => shared = Some(SharedSize { key: im.key.clone(), width: im.width, height: im.height, size: (w, h) }), + } + } + shared.filter(|s| s.size.0.is_finite() && s.size.1.is_finite() && s.size.0 > 0.0 && s.size.1 > 0.0) + } +} + +/// The size the pixels of image `key` (`width` × `height` pixels) are resampled for while the +/// pieces of a mesh-warped image are written ([`Exporter::warp_image_size`]). +pub(super) struct SharedSize { + key: String, + width: u32, + height: u32, + size: (f64, f64), } /// The size image `im` is placed at: its pixel grid's sides through its transform. @@ -320,13 +376,48 @@ mod tests { let image = ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; assert!(exporter.colorized_image(&image, &paint, true).is_none(), "{key}"); - let cache = format!("{key}\u{0}{paint:?}\u{0}1.000x1.000"); + let cache = format!("{key}\u{0}{paint:?}"); assert!(matches!(exporter.images.get(&cache), Some(None)), "rejection for {key} was not cached"); assert!(exporter.colorized_image(&image, &paint, true).is_none(), "second call for {key}"); } assert_eq!(exporter.images.len(), 3); } + /// Without downsampling, a colorized image and a CMYK image with a grid mask are made once for + /// every size they are placed at. + #[test] + fn colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling() { + let gray = |pixels: Vec<u8>| { + let mut png = vec![]; + image::GrayImage::from_raw(2, 2, pixels).unwrap().write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + ImageBlob::png(png) + }; + let mut doc = Document::new(100.0, 100.0); + doc.images.insert("pic".into(), gray(vec![0, 255, 128, 64])); + doc.images.insert("alpha".into(), gray(vec![255, 128, 0, 64])); + let inks = vectorcraft_doc::cmyk::Inks::new(2, 2, [0u8, 102, 255, 0].repeat(4)).unwrap(); + doc.images.insert("inks".into(), ImageBlob::cmyk_tiff(&inks).unwrap()); + let settings = crate::PdfSettings::default(); + let mut exporter = Exporter::new(&doc, &settings); + let paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let image = |key: &str, scale: f64| ImageObject { + key: key.into(), + width: 2, + height: 2, + xf: Affine::scale(scale), + link: None, + placement: Default::default(), + pixels: None, + }; + for scale in [10.0, 20.0] { + assert!(exporter.colorized_image(&image("pic", scale), &paint, true).is_some(), "colorized at {scale}"); + let art = vectorcraft_doc::Node::new(doc.layers[0].id, vectorcraft_doc::NodeKind::Image(Box::new(image("alpha", scale)))); + let mask = vectorcraft_doc::OpacityMask::new(art, true); + assert!(exporter.masked_image(&image("inks", scale), &mask, true).is_some(), "masked at {scale}"); + } + assert_eq!(exporter.images.len(), 2, "{:?}", exporter.images.keys().collect::<Vec<_>>()); + } + #[test] fn tinted_color_rejects_oversized_and_overflowing_buffers() { let max = usize::try_from(vectorcraft_doc::MAX_RASTER_ALLOC).unwrap_or(usize::MAX); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index bb73980177..8bafe017c6 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -100,6 +100,17 @@ impl Exporter<'_> { } } + /// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a + /// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels + /// are resampled for ([`Self::warp_image_size`]). + fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) { + let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w)); + self.measured_children(s, children, page, measured); + if let Some(outer) = outer { + self.warp_size = outer; + } + } + /// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white, /// then black through an alpha mask of `c` (at full object opacity without its own mask, unless /// those define its shape). @@ -268,7 +279,7 @@ impl Exporter<'_> { } match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); + self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice())); } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // The region every output clips to; nothing to clip by hides the clipped art. The diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 60d7a9bd67..207c9fc595 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -133,6 +133,39 @@ fn colorized_images_are_downsampled() { assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi"); } +/// The pieces of a colorized image under a mesh warp, placed at different widths, paint one +/// colorized image and its one soft mask, with downsampling off and on. +#[test] +fn a_colorized_warped_image_is_written_once_for_all_its_pieces() { + for compression in [serde_json::json!({}), serde_json::json!({"gray": {"downsample": "bicubic", "ppi": 150}})] { + let mut d = Document::new(100.0, 100.0); + let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8])); + let mut png = vec![]; + gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("pic".into(), ImageBlob::png(png)); + let im = ImageObject { + key: "pic".into(), + width: 600, + height: 600, + xf: Affine::scale(72.0 / 600.0), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + // 0.5 to 1.5 times as wide from left to right. + let stretch = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x * (0.5 + p.x / 144.0), p.y); + let warped = vectorcraft_doc::live::mesh_warp_image(&n, &im, &stretch, 4, false, false); + d.insert(d.default_layer(), 0, warped).unwrap(); + let settings: PdfSettings = serde_json::from_value(serde_json::json!({ "compression": compression })).unwrap(); + let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let found = images(&out); + assert_eq!(found.len(), 2, "{compression}: the colorized image and its soft mask, not {found:?}"); + assert!(found.contains(&("DeviceRGB".into(), vec![], true)), "{compression}: {found:?}"); + } +} + /// Printed separations keep a colorized image's pixels on every plate and separate its color: /// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels /// is separated as before. diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 9e012e330a..c02f3f5e1c 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -5,10 +5,11 @@ use std::io::Cursor; +use craft_pdf::syntax::object::{Name, Object}; use image::{DynamicImage, ImageFormat}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; -use vectorcraft_geom::Affine; +use vectorcraft_geom::{Affine, Point}; use crate::*; @@ -72,6 +73,55 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() { assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); } +/// The width and height of each image XObject in `pdf`. +fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + file.objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => { + Some((s.dict().get::<u32>(b"Width").unwrap_or(0), s.dict().get::<u32>(b"Height").unwrap_or(0))) + } + _ => None, + }) + .collect() +} + +/// A mesh warp that stretches the image across more toward its right side cuts it into pieces +/// placed at different widths. With downsampling on, every piece paints one image, resampled for +/// the widest piece; another placement of the image keeps its own resampling. +#[test] +fn a_warped_image_is_written_once_for_all_its_pieces() { + let compression = json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}); + for masked in [false, true] { + let mut d = doc(encoded(colour(), ImageFormat::Png), "image/png"); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node(id).unwrap().clone(); + let NodeKind::Image(im) = &n.kind else { panic!("the image") }; + // 0.5 to 1.5 times as wide from left to right, within the page. + let stretch = |p: Point| Point::new(p.x * (0.5 + p.x / 144.0), p.y); + *d.node_mut(id).unwrap() = vectorcraft_doc::live::mesh_warp_image(&n, im, &stretch, 4, false, masked); + let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings: settings.clone(), ..Default::default() }).unwrap().bytes; + let sizes = image_sizes(&bytes); + assert_eq!(sizes.len(), 1, "masked {masked}: one image for 32 pieces, not {sizes:?}"); + // One inch high (150 pixels at 150 ppi), and wider than one inch at its widest. + let (w, h) = sizes[0]; + assert!(h == 150 && w > 150 && w < 600, "masked {masked}: {w} × {h}"); + assert!(String::from_utf8_lossy(&bytes).matches(" Do").count() >= 32, "masked {masked}: every piece paints the image"); + + // The image placed again, two inches wide and high, is resampled for that placement. + let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(ImageObject { xf: Affine::scale(144.0 / 600.0), ..(**im).clone() }))); + let l = d.layers[0].id; + d.insert(Some(l), 1, plain).unwrap(); + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let mut sizes = image_sizes(&bytes); + sizes.sort_unstable(); + assert_eq!(sizes, [(w, h), (300, 300)], "masked {masked}"); + } +} + #[test] fn jpeg_zip_and_automatic_compression() { let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..667b21983a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1065,6 +1065,8 @@ Images follow the `compression` settings of their kind (`color`, `gray`, or `mon `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 lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them. +The pieces of an image bent by a Warp effect are resampled once, for the widest and the highest size any piece places +the image at, and all paint that one image. A 16-bit PNG that is not resampled, recompressed or converted keeps its 16 bits a sample in PDF 1.5 and later files, and is written at 8 bits in PDF 1.3 and 1.4 files. CMYK images stay CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged, as in PDF/X-3 and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..ec5754c624 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -616,6 +616,7 @@ since 2026-10-01. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. +- **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. From 5467e4e87fe856ed931b319bdd97b10564b15a76 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:00:39 -0800 Subject: [PATCH 21/27] Tests: the floating Character font wheel test turns off missing glyph protection The test's second and third wheel turns step to whichever families the process has loaded at that moment. When another test has loaded a test font without Latin glyphs, such as Alias Jointest Sans from the Glyphs panel tests, the turn applies it, missing glyph protection keeps "Hi" in Source Serif 4, and the assert_ne at the second turn fails. The test now turns the preference off, as the font menu tests in tests/font_menu.rs do, so each turn changes the text's font whatever family it reaches. --- crates/ui-egui/src/panel_docking/native_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 2f1d0d536c..368f7ebae8 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -1039,6 +1039,9 @@ fn ui_docking_leaves_out_the_controls_of_os_windows() { #[test] fn wheel_over_a_floating_character_font_field_changes_the_font() { let mut h = Harness::new(); + // The later turns step to whichever families the process has loaded, test fonts without Latin + // glyphs included. Missing glyph protection would keep the text in its previous font. + h.app.run("prefs.set", json!({"key": "missingGlyphProtection", "value": false})).unwrap(); h.app.run("text.create", json!({"x": 30, "y": 50, "text": "Hi", "font": "Source Sans 3"})).unwrap(); let viewport = h.float("character"); let field = h From 185ba12b8c4648054b4f2bebd7ca67b9825c389c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:08:24 -0800 Subject: [PATCH 22/27] .ai export: opacity mask art keeps its live effects in the editing data An object in an opacity mask's art is written with its art style, as on the layers, so a mask whose art has Offset Path opens from the editing data with the effect live and draws the masked object as it did. This covers a hidden art root and masks inside another mask's art. Geometry effects on mask art that no art style holds, such as Roughen, are applied to the art written, in the masks of layers, symbols and pattern tiles. Before, the mask's art was written without them. --- crates/eps/src/ai_editing.rs | 105 ++++++++++++++- crates/eps/src/ai_editing/tests.rs | 143 ++++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 20 ++- crates/eps/src/ai_editing/writer/styles.rs | 12 +- docs/target-app-parity.md | 1 + 5 files changed, 268 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index cf068da1ef..0048cb945f 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -186,6 +186,7 @@ //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). +use std::collections::HashSet; use std::fmt::Write; use std::sync::Arc; @@ -385,9 +386,15 @@ fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> { } /// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]), -/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are -/// written as); `None` when nothing changes. +/// in its symbols' art, its patterns' tile art (which instances and pattern fills are written as) +/// and its opacity masks' art too ([`baked_masks`]); `None` when nothing changes. fn baked(doc: &Document) -> Option<Document> { + let art = baked_art(doc); + baked_masks(art.as_ref().unwrap_or(doc)).or(art) +} + +/// [`baked`] for the layers, symbols and patterns, leaving opacity masks' art as it is. +fn baked_art(doc: &Document) -> Option<Document> { let baked = vectorcraft_effects::bake_document(doc); let d = baked.as_ref().unwrap_or(doc); let needs = vectorcraft_effects::needs_bake; @@ -416,6 +423,100 @@ fn baked(doc: &Document) -> Option<Document> { Some(out) } +/// `doc` with the art of the opacity masks in its layers', symbols' and patterns' art baked as +/// [`baked_art`] bakes the layers. Each round bakes the art of the outermost masks whose art needs +/// it, and the next round the masks inside that art, at most [`writer::MAX_NEST`] rounds (the +/// writer leaves masks nested deeper out). Baked art keeps its id, by which the writer finds the +/// art as it was before baking ([`writer::styled_objects`]). `None` when no mask's art needs baking. +fn baked_masks(doc: &Document) -> Option<Document> { + let mut out: Option<Document> = None; + // The masks' art baked so far, by id: it isn't baked again. + let mut done = HashSet::new(); + for _ in 0..writer::MAX_NEST { + let d = out.as_ref().unwrap_or(doc); + let roots = || d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| p.art.iter())); + let mut arts = vec![]; + for n in roots() { + mask_arts(n, &done, &mut arts); + } + if arts.is_empty() { + break; + } + // Each art baked as a layer of a copy of the document, as [`baked_art`] bakes symbols. + let mut copy = d.clone(); + copy.layers = arts.iter().map(|a| Arc::new(Node::group(vectorcraft_doc::NodeId(0), vec![a.clone()]))).collect(); + let Some(mut next) = vectorcraft_effects::bake_document(©) else { break }; + let layers = std::mem::replace(&mut next.layers, d.layers.clone()); + let mut baked = layers.iter().map(|l| l.children().and_then(|c| c.first().cloned())); + for l in next.layers.iter_mut() { + if let Some(n) = with_baked_masks(l, &done, &mut baked) { + *l = n; + } + } + for s in next.symbols.iter_mut() { + if let Some(n) = with_baked_masks(&s.art, &done, &mut baked) { + s.art = n; + } + } + for p in next.patterns.iter_mut() { + for a in p.art.iter_mut() { + if let Some(n) = with_baked_masks(a, &done, &mut baked) { + *a = n; + } + } + } + done.extend(arts.iter().map(|a| a.id)); + out = Some(next); + } + out +} + +/// Does [`baked_masks`] bake the art of mask `m` in this round? +fn bakes_mask_art(m: &vectorcraft_doc::OpacityMask, done: &HashSet<vectorcraft_doc::NodeId>) -> bool { + !done.contains(&m.art.id) && vectorcraft_effects::needs_bake(&m.art) +} + +/// The art of the opacity masks in `n` that [`baked_masks`] bakes in this round, in the order +/// [`with_baked_masks`] puts them back: a mask's art before the object's members, and the masks +/// inside art that isn't baked. +fn mask_arts(n: &Node, done: &HashSet<vectorcraft_doc::NodeId>, out: &mut Vec<Arc<Node>>) { + if let Some(m) = n.extras().mask.as_ref() { + if bakes_mask_art(m, done) { + out.push(m.art.clone()); + } else { + mask_arts(&m.art, done, out); + } + } + for c in n.children().into_iter().flatten() { + mask_arts(c, done, out); + } +} + +/// `n` with the art of its opacity masks taken from `baked` in the order [`mask_arts`] lists it; +/// `None` when nothing in it changes. +fn with_baked_masks(n: &Arc<Node>, done: &HashSet<vectorcraft_doc::NodeId>, baked: &mut dyn Iterator<Item = Option<Arc<Node>>>) -> Option<Arc<Node>> { + let art = + n.extras().mask.as_ref().and_then(|m| if bakes_mask_art(m, done) { baked.next().flatten() } else { with_baked_masks(&m.art, done, baked) }); + let members: Vec<Option<Arc<Node>>> = n.children().map(|c| c.iter().map(|c| with_baked_masks(c, done, baked)).collect()).unwrap_or_default(); + if art.is_none() && members.iter().all(Option::is_none) { + return None; + } + let mut m = (**n).clone(); + if let Some(a) = art + && let Some(mask) = m.extras_if_any().and_then(|e| e.mask.as_mut()) + { + mask.art = a; + } + if let Some(children) = m.children_mut() { + for (c, new) in children.iter_mut().zip(members) { + if let Some(new) = new { + *c = new; + } + } + } + Some(Arc::new(m)) +} + /// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with /// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers /// that go with that form; and what it couldn't hold. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index d998ff8e3d..68681abf51 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6538,6 +6538,132 @@ fn effects_in_a_styled_objects_mask_are_warned_about() { assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n")); } +/// A 200 × 100 document of an image whose opacity mask doesn't clip, of the art `art` makes. +fn masked_image(art: impl FnOnce(&mut Document) -> Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None); + let art = art(&mut doc); + im.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into()); + doc.insert(Some(layer), usize::MAX, im).unwrap(); + doc +} + +/// The opacity mask art of the first image in `doc` that has one. +fn image_mask_art(doc: &Document) -> Arc<Node> { + let mut art = None; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Image(_)) + && art.is_none() + && let Some(m) = n.extras().mask.as_ref() + { + art = Some(m.art.clone()); + } + }); + art.expect("an image with an opacity mask") +} + +/// How many of the artboard's pixels `a` and `b` draw differently. +fn pixels_apart(a: &Document, b: &Document) -> usize { + let (a, b) = (vectorcraft_testkit::raster::render_artboard(a), vectorcraft_testkit::raster::render_artboard(b)); + assert_eq!((a.width, a.height), (b.width, b.height)); + (0..a.height).flat_map(|y| (0..a.width).map(move |x| (x, y))).filter(|&(x, y)| a.pixel(x, y) != b.pixel(x, y)).count() +} + +/// An opacity mask's art keeps its live effects as an art style: an image masked by a black path +/// with Offset Path (hiding the image under it) reads back with the effect live on the mask's path, +/// alone or as a member of a group of two, and draws as it did. +#[test] +fn a_masked_image_with_live_offset_path_round_trips() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 8.0, "joins": "miter", "miterLimit": 4.0})); + let black = solid(Color::rgb(0.0, 0.0, 0.0)); + for members in [1, 2] { + let made = |effects: Vec<vectorcraft_doc::Effect>| { + masked_image(|doc| { + let grown = styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), black.clone(), Paint::None, effects); + if members == 1 { + return grown; + } + let plain = square(doc, Rect::new(130.0, 30.0, 170.0, 70.0), Color::rgb(0.0, 0.0, 0.0)); + group(doc, vec![grown, plain], false) + }) + }; + let doc = made(vec![offset.clone()]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "{members}: the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{members}: {:?}", out.warnings); + let back = read_back(&doc); + let art = image_mask_art(&back); + let grown = if members == 1 { + art.clone() + } else { + assert!(art.appearance.effects.is_empty(), "the group took the effect: {:?}", art.appearance.effects); + let children = art.children().expect("a group of the mask's art"); + assert_eq!(children.len(), 2); + assert!(children[1].appearance.effects.is_empty()); + children[0].clone() + }; + assert!(matches!(grown.kind, NodeKind::Path { .. }), "{members}: {:?}", grown.kind); + let [e] = grown.appearance.effects.as_slice() else { panic!("{members}: {:?}", grown.appearance.effects) }; + assert_eq!(e.id, "path.offsetPath"); + assert_eq!(e.params.get("offset").and_then(serde_json::Value::as_f64), Some(8.0), "{:?}", e.params); + assert_eq!(pixels_apart(&doc, &back), 0, "{members}"); + } +} + +/// The art of a mask keeps Offset Path live when its root is hidden (the canvas draws it all the +/// same) and when it is the art of a mask inside another mask's art (that clips). +#[test] +fn hidden_and_nested_mask_art_keep_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let grown = |doc: &mut Document, effects, gray: f32| { + styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), solid(Color::rgb(gray, gray, gray)), Paint::None, effects) + }; + let hidden = |effects| { + masked_image(|doc| { + let mut art = grown(doc, effects, 0.0); + art.visible = false; + art + }) + }; + let nested = |effects| { + masked_image(|doc| { + let mut outer = square(doc, Rect::new(20.0, 15.0, 180.0, 85.0), Color::rgb(0.0, 0.0, 0.0)); + let inner = grown(doc, effects, 1.0); + outer.extras_mut().mask = Some(OpacityMask { art: Arc::new(inner), clip: true, invert: false, disabled: false, linked: true }.into()); + outer + }) + }; + for (case, made) in [("hidden", &hidden as &dyn Fn(Vec<vectorcraft_doc::Effect>) -> Document), ("nested", &nested)] { + let doc = made(vec![offset.clone()]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "{case}: the effect draws nothing"); + let back = read_back(&doc); + let mut art = image_mask_art(&back); + if case == "nested" { + art = art.extras().mask.as_ref().expect("the mask inside the mask's art").art.clone(); + } + let ids: Vec<&str> = art.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(ids, ["path.offsetPath"], "{case}"); + assert_eq!(pixels_apart(&doc, &back), 0, "{case}"); + } +} + +/// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the +/// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the +/// written points' rounding moves. +#[test] +fn a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws() { + let rough = fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"})); + let black = solid(Color::rgb(0.0, 0.0, 0.0)); + let rect = |doc: &mut Document, effects| styled_rect(doc, "Rough", Rect::new(60.0, 30.0, 140.0, 70.0), black.clone(), Paint::None, effects); + let doc = masked_image(|doc| rect(doc, vec![rough])); + assert!(pixels_apart(&doc, &masked_image(|doc| rect(doc, vec![]))) > 0, "the effect draws nothing"); + let back = read_back(&doc); + assert!(image_mask_art(&back).appearance.effects.is_empty()); + let apart = pixels_apart(&doc, &back); + assert!(apart <= 4, "{apart} pixels differ"); +} + /// On a large canvas no art style is written (the reader scales the styles of a large canvas): /// objects with effects are written as their vector art with their geometry effects applied, with a /// warning. @@ -7625,8 +7751,8 @@ fn a_style_that_isnt_written_defines_no_gradient() { assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}"); } -/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One -/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is +/// An object in a plain group, a sublayer, a clipping group by a path or an opacity mask keeps its +/// style there. One in a symbol's art or in art cut to a clipping object other than a path is /// written as its drawn look, with a warning. #[test] fn where_a_styled_object_keeps_its_style() { @@ -7683,8 +7809,17 @@ fn where_a_styled_object_keeps_its_style() { n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); put(&mut doc, n); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); - assert!(!shown(&out.program).contains("(Anon 1) XW")); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + let mut art = None; + back.walk(|n| { + if let Some(m) = n.extras().mask.as_ref() { + art = Some(m.art.clone()); + } + }); + let art = art.expect("the opacity mask"); + assert_eq!(art.name.as_deref(), Some("Inside")); + assert_eq!(art.appearance.effects.len(), 1); // In art cut to a clipping object other than a path: type. let mut doc = Document::new(200.0, 100.0); let t = vectorcraft_doc::TextObject::point( diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 7db5d11896..ce937c9048 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -47,7 +47,7 @@ pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES}; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left /// out). Groups and layers need no limit of their own: a document is never deeper than /// [`vectorcraft_doc::MAX_DEPTH`]. -const MAX_NEST: u32 = 8; +pub(super) const MAX_NEST: u32 = 8; pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; @@ -200,6 +200,9 @@ pub(super) struct Writer<'a> { /// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the /// art being written is in. hidden: u32, + /// The `hidden` and `nest` values at which the objects of the opacity mask's art being written + /// are written: objects there may be written with their art styles. + mask_level: Option<(u32, u32)>, /// The image just written was written as a placed file. placed: bool, /// The type just written was written as type, not as its outlines. @@ -289,6 +292,7 @@ impl<'a> Writer<'a> { canvas_origin: origin, payloads: vec![], hidden: 0, + mask_level: None, placed: false, typed: false, bound: vec![], @@ -613,10 +617,10 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } - // On the layers, outside art cut to a clipping object: with its art style, when it has one. + // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its + // art style, when it has one. if !self.in_look - && self.hidden == 0 - && self.nest == 0 + && ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest))) && self.clip.is_none() && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() @@ -843,21 +847,27 @@ impl<'a> Writer<'a> { }; // A plain group of two or more objects, or of none, is the group the art is read into. A group // of one object is written as a group, because the reader makes one shown object the art. + // A group that may be written with its art style is written as one object. let plain = matches!(art.kind, NodeKind::Group { clip: false, .. }) && Xy::of(art) == Xy::PLAIN && art.visible && !art.locked && art.name.as_deref().is_none_or(str::is_empty) - && art.extras().mask.is_none(); + && art.extras().mask.is_none() + && !self.styled.contains_key(&art.id); let members = match art.children() { Some(c) if plain && c.len() != 1 => c.as_slice(), _ => std::slice::from_ref(art), }; self.nest += 1; self.hidden += 1; + // The art's objects (not those inside its symbols, live objects or pattern tiles, which are + // deeper) may be written with their art styles. + let level = self.mask_level.replace((self.hidden, self.nest)); for c in members { self.node(c); } + self.mask_level = level; self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index fe3e68ce24..163d70381c 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -39,8 +39,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>() } -/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic +/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art +/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes +/// it): paths and compound paths with points and groups, with live effects, linked to a graphic /// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp /// ([`warped_image`]), without Opacity & Mask Define /// Knockout Shape (the reader takes an object's transparency @@ -52,6 +53,13 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, A if candidate(n) { out.insert(n.id, n.clone()); } + if let Some(m) = n.extras().mask.as_ref() { + let mut art = m.art.clone(); + if !art.visible { + Arc::make_mut(&mut art).visible = true; + } + walk(std::slice::from_ref(&art), out); + } // A group's members are written with a style of their own only when the group is written // without one. Baking a container with effects of its own changes its members, which // keep their ids: they are written as baking left them. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..c79a30f7b7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1594,6 +1594,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). + The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. From d5e2a95403bba4fb3d097001893f867962e4d852 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:21:38 -0800 Subject: [PATCH 23/27] i18n: Hindi rows no longer keep English words or garble their sentences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 264 Hindi rows kept English words in a Hindi sentence ("or", "give", "see"), dropped the word that names what is wrong ("unknown align" read as "unknown `left`"), reversed a condition ("not editing an opacity mask" read as "do not edit an opacity mask"), translated enum values the command reads in English, broke a list of accepted values ("(vertical|horizontal|free)" read as "(vertical Chhorizontal Chlorfree)"), or repeated a parameter as a "size: आकार" prefix. Each row now says what its English says, with parameter names, enum values, placeholders and value lists unchanged, and "positive" reads धनात्मक. Two tests check every row: no English connecting word outside backticks, quotes, braces and brackets, and every `a|b|c` list of an `@msg` row kept as written. --- crates/ui-egui/src/i18n/hi.tsv | 528 +++++++++++++++---------------- crates/ui-egui/src/i18n/tests.rs | 53 ++++ docs/localization-hi.md | 7 + docs/target-app-parity.md | 1 + 4 files changed, 325 insertions(+), 264 deletions(-) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3b8986e5f1..6933bf581f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1243,7 +1243,7 @@ antiAlias Sharp शार्प Delete the selected items and their artwork? चुने गए आइटम और उनका आर्टवर्क हटाएँ? Delete this layer and its artwork? यह लेयर और इसका आर्टवर्क हटाएँ? Delete “{name}”? "{name}" को हटाएं? - Delete {kind} Style Delete {kind} Style + Delete {kind} Style {kind} स्टाइल हटाएँ Delete Action Sets एक्शन सेट हटाएं Delete All सभी हटाएँ Delete All Slices सभी स्लाइस हटाएँ @@ -1271,7 +1271,7 @@ antiAlias Sharp शार्प Delete Instances इंस्टेंस हटाएँ Delete Layer लेयर हटाएँ Delete Library लाइब्रेरी हटाएँ - Delete Library Group लाइब्रेरी समूह + Delete Library Group लाइब्रेरी ग्रुप हटाएँ Delete Library Item लाइब्रेरी आइटम हटाएं Delete Light लाइट हटाएँ Delete Mesh Point मेश पॉइंट हटाएँ @@ -1774,7 +1774,7 @@ antiAlias Sharp शार्प Export Keyboard Shortcuts as Text… कीबोर्ड शॉर्टकट टेक्स्ट के रूप में निर्यात करें… Export Text… टेक्स्ट निर्यात करें… Export Layer निर्यात लेयर - Export Library लाइब्रेरी + Export Library लाइब्रेरी एक्सपोर्ट करें Export Library… लाइब्रेरी निर्यात करें… Export Option निर्यात ऑप्शन Export PDF PDF निर्यात करें @@ -1855,7 +1855,7 @@ antiAlias Sharp शार्प Files of a PDF standard can't be password-protected. एक PDF मानक की फ़ाइलें पासवर्ड की रक्षा नहीं की जा सकती है। Files of this standard tag their colours with ICC profiles. इस मानक की फ़ाइलें ICC प्रोफ़ाइल के साथ उनके रंगों को टैग करती हैं। Fill फ़िल - Fill (X), double-click for the Color Picker रंग पिकर के लिए डबल क्लिक करें + Fill (X), double-click for the Color Picker फ़िल (X), कलर पिकर के लिए डबल-क्लिक करें Fill & Stroke भरें और स्ट्रोक करें Fill and Stroke भरें और स्ट्रोक करें Fill and Stroke Controls भरण और स्ट्रोक नियंत्रण @@ -1922,7 +1922,7 @@ antiAlias Sharp शार्प Fix All सभी को ठीक करें Fix All Issues सभी मुद्दों को ठीक करें Fix Issues मुद्दों को ठीक करें - Fix Selected निर्धारित करना + Fix Selected सिलेक्टेड को ठीक करें Fixed निश्चित Flag… झंडा… Flare फ्लेयर @@ -2302,8 +2302,8 @@ antiAlias Sharp शार्प If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. अगर ड्रॉइंग टैबलेट के पेन से ड्रॉ नहीं हो रहा, तो X11 (XWayland) ऑप्शन चुनें। Ignore अनदेखा करें Ignore Artboards (all the art as one page) आर्टबोर्ड्स नज़रअंदाज़ करें (सारा आर्टवर्क एक पेज पर) - Ignore Color Ignore रंग - Ignore White Ignore व्हाइट + Ignore Color रंग अनदेखा करें + Ignore White सफ़ेद अनदेखा करें Image इमेज Image Info इमेज जानकारी Image Map: इमेज मानचित्र: @@ -2956,7 +2956,7 @@ antiAlias Sharp शार्प Nesting too deep to follow का पालन करने के लिए बहुत गहरी Neutral Gradients न्यूट्रल ग्रेडिएंट्स New नया - New {kind} Style… नया {kind} Style… + New {kind} Style… नई {kind} स्टाइल… New Action नया एक्शन New Action… नया एक्शन… New Art Appearance नई कला उपस्थिति @@ -3134,8 +3134,8 @@ antiAlias Sharp शार्प Odd Fields विषम फ़ील्ड्स Odd-Numbered Anchors Odd-Numbered एंकर Odia ओडिया - of of - of {count} {count} + of / + of {count} / {count} of characters used वर्णों का इस्तेमाल किया Off बंद Off-Artboard Art ऑफ आर्टबोर्ड आर्ट @@ -3183,7 +3183,7 @@ antiAlias Sharp शार्प Opacity: ओपेसिटी: Opacity: {value} ओपेसिटी: {value} Opacity: 50% Multiply ओपेसिटी: 50% मल्टिप्लाई - Opacity: Default Opacity: डिफ़ॉल्ट + Opacity: Default ओपेसिटी: डिफ़ॉल्ट Opaque अपारदर्शी Open ओपन Open a document to check it for issues. इसे मुद्दों के लिए जांच करने के लिए एक डॉक्यूमेंट ओपन करें। @@ -3600,8 +3600,8 @@ antiAlias Sharp शार्प Preserve Text Editability टेक्स्ट संपादन क्षमता को संरक्षित करना Preserve Tints टिंट्स बनाए रखें Preserve: बनाए रखें: - Preset {} Preset {} - Preset {number} Preset {number} + Preset {} प्रीसेट {} + Preset {number} प्रीसेट {number} PRESET DETAILS प्रीसेट विवरण Preset Name प्रीसेट का नाम Preset name: प्रीसेट का नाम: @@ -3797,9 +3797,9 @@ antiAlias Sharp शार्प Rectangular Grid Tool आयताकार ग्रिड टूल Rectangular Grid Tool Options आयताकार ग्रिड टूल ऑप्शन्स Red लाल - Redefine {kind} Style Redefine {kind} स्टाइल + Redefine {kind} Style {kind} स्टाइल फिर से डिफ़ाइन करें Redefine {name} {name} फिर से डिफ़ाइन करें - Redefine Character Style रेडफाइन कैरेक्टर स्टाइल + Redefine Character Style कैरेक्टर स्टाइल फिर से डिफ़ाइन करें Redefine Graphic Style ग्राफ़िक स्टाइल फिर से डिफ़ाइन करें Redefine Graphic Style “{name}” ग्राफ़िक स्टाइल “{name}” फिर से डिफ़ाइन करें Redefine Paragraph Style पैराग्राफ़ स्टाइल फिर से डिफ़ाइन करें @@ -4167,7 +4167,7 @@ antiAlias Sharp शार्प Save Document डॉक्यूमेंट सेव करें Save Document Info… डॉक्यूमेंट जानकारी सेव करें… Save each artboard to a separate file प्रत्येक आर्टबोर्ड को एक अलग फ़ाइल में सेव करें - Save Export Preset सेविंग प्रीसेट + Save Export Preset एक्सपोर्ट प्रीसेट सेव करें Save for Office Documents कार्यालय डॉक्यूमेंट्स के लिए सेव करें Save for Office Documents… कार्यालय डॉक्यूमेंट्स के लिए सेव करें… Save for Web वेब के लिए सेव करें @@ -4464,7 +4464,7 @@ antiAlias Sharp शार्प Shadow Intensity शैडो तीव्रता Shadows and Glows छाया और चमक Game HUD गेम HUD - Health Fill स्वास्थ्य + Health Fill हेल्थ फ़िल Mana Glow माना Damage Flash क्षति Quest Outline क्वेस्ट @@ -4527,7 +4527,7 @@ antiAlias Sharp शार्प Shortcuts शॉर्टकट्स Shorter Than Length लंबाई से कम Show दिखाएँ - Show {kind} Brushes शो {kind} ब्रश + Show {kind} Brushes {kind} ब्रश दिखाएँ Show Active Plane Widget सक्रिय प्लेन विजेट दिखाएं Show All सभी दिखाएँ Show All Hidden Attributes सभी छिपे हुए गुण दिखाएँ @@ -4866,7 +4866,7 @@ antiAlias Sharp शार्प Strikethrough स्ट्राइकथ्रू String not closed स्ट्रिंग बंद नहीं Stroke स्ट्रोक - Stroke (X), double-click for the Color Picker स्ट्रोक (एक्स), रंग पिकर के लिए डबल क्लिक करें + Stroke (X), double-click for the Color Picker स्ट्रोक (X), कलर पिकर के लिए डबल-क्लिक करें Stroke Color स्ट्रोक रंग Stroke Direction स्ट्रोक दिशा Stroke Gradient स्ट्रोक ग्रेडिएंट @@ -5173,8 +5173,8 @@ antiAlias Sharp शार्प Transform रूपांतरण करें Transform Again फिर से बदलना Transform Both दोनों को बदलना - Transform Each प्रत्येक को बदलना - Transform Each… प्रत्येक ट्रांसफॉर्मर… + Transform Each प्रत्येक को ट्रांसफ़ॉर्म करें + Transform Each… प्रत्येक को ट्रांसफ़ॉर्म करें… Transform in Perspective पर्सपेक्टिव में रूपांतरण Transform Object Only केवल ऑब्जेक्ट को ट्रांसफॉर्म करें Transform Objects ऑब्जेक्ट को रूपांतरित करें @@ -5357,7 +5357,7 @@ antiAlias Sharp शार्प User Graphic Style Library 9 उपयोगकर्ता ग्राफिक स्टाइल लाइब्रेरी 9 User Interface उपयोगकर्ता इंटरफ़ेस User Library उपयोगकर्ता लाइब्रेरी - User Preset उपयोगकर्ता Preset + User Preset उपयोगकर्ता प्रीसेट User Swatch Library 1 यूज़र स्वॉच लाइब्रेरी 1 User Swatch Library 10 यूज़र स्वॉच लाइब्रेरी 10 User Swatch Library 2 यूज़र स्वॉच लाइब्रेरी 2 @@ -5739,7 +5739,7 @@ antiAlias Sharp शार्प @msg {_1} is {_2} bytes (limit {_3}) {_1} {_2} बाइट्स का है (सीमा {_3}) @msg {_1} is a live effect (Effect › Plug-ins) {_1} एक लाइव इफ़ेक्ट (इफ़ेक्टी> प्लग-इन) है। @msg {_1} is already used by {_2} {_1} का उपयोग पहले से ही {_2} द्वारा किया जाता है -@msg {_1} is an exporter (File › Export › Export As…) {_1} एक निर्यातक (फ़ाइल> निर्यात › निर्यात As...) है। +@msg {_1} is an exporter (File › Export › Export As…) {_1} एक एक्सपोर्टर है (फ़ाइल › एक्सपोर्ट › इस रूप में एक्सपोर्ट करें…) @msg {_1} is not a file {_1} एक फ़ाइल नहीं है @msg {_1} is reserved by the system {_1} सिस्टम द्वारा आरक्षित है @msg {_1} isn't a path {_1} एक पाथ नहीं है @@ -5792,8 +5792,8 @@ antiAlias Sharp शार्प @msg {key} must be 0–{MAX_ADD_AROUND} pt {key} होना चाहिए 0-{MAX_ADD_AROUND} pt @msg {key} must be a finite coordinate {key} एक परिमित निर्देशांक होना चाहिए @msg {key} must be a percentage {min}..100 {key} को {min}..100 के बीच प्रतिशत होना चाहिए -@msg {key} must be a positive number, not {n} {key} एक सकारात्मक संख्या होना चाहिए, नहीं {n} -@msg {key} must be a positive number, not {v} {key} एक सकारात्मक संख्या होना चाहिए, नहीं {v} +@msg {key} must be a positive number, not {n} {key} एक धनात्मक संख्या होनी चाहिए, {n} नहीं +@msg {key} must be a positive number, not {v} {key} एक धनात्मक संख्या होनी चाहिए, {v} नहीं @msg {key} must be an array of non-negative integer object ids {key} को गैर-नकारात्मक पूर्णांक ऑब्जेक्ट ids की एक सरणी होना चाहिए @msg {key} must be one of {_1} {key} {_1} में से एक होना चाहिए @msg {key} must be Unix seconds (an integer) or null {key} को यूनिक्स सेकंड (एक पूर्णांक) या null होना चाहिए @@ -5807,7 +5807,7 @@ antiAlias Sharp शार्प @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} इमेज से ठोस नहीं बना सकता: पहले ऑब्जेक्ट › इमेज ट्रेस › बनाएँ से उसे ट्रेस करें। @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक आर्टबोर्ड के साथ फिर से खुलती है। सभी आर्टबोर्ड रखने के लिए संपादन क्षमताओं को संरक्षित करना विकल्प चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए -@msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए +@msg {label} must be a number above 0 and at most {max} {label} 0 से बड़ी और अधिकतम {max} संख्या होनी चाहिए @msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए @msg {label} must be one character {label} एक वर्ण होना चाहिए @msg at most {max} tab stops अधिकतम {max} टैब स्टॉप @@ -5816,7 +5816,7 @@ antiAlias Sharp शार्प @msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए @msg {label} must be a whole number from {min} to {max} {label} को {min} से {max} तक एक पूर्ण संख्या होना चाहिए @msg {label} must be a number from 1 to 10000 {label} को 1 से 10000 तक एक नंबर होना चाहिए -@msg {label} must be a number of at least 0.1 {label} को कम से कम 0.1 होना चाहिए। +@msg {label} must be a number of at least 0.1 {label} कम से कम 0.1 की संख्या होनी चाहिए @msg {label} produced an empty result {label} ने खाली परिणाम का उत्पादन किया @msg {LABEL} produced an empty result {LABEL} ने खाली परिणाम का उत्पादन किया @msg {label} requires a shape with at least two anchors {label} को कम से कम दो एंकरों के साथ एक आकार की आवश्यकता होती है @@ -5829,7 +5829,7 @@ antiAlias Sharp शार्प @msg {name} must be {lo}–{hi}{unit} (got {v}) {name} को {lo}-{hi}{unit} ({v}) होना चाहिए। @msg {name}: {e} {name}: {e} @msg {name}: CRC mismatch {name}: CRC mismatch -@msg {name}: CRC or size mismatch {name}: CRC or size mismatch +@msg {name}: CRC or size mismatch {name}: CRC या आकार मेल नहीं खाता @msg {name}: no local header {name}: कोई स्थानीय हैडर नहीं @msg {name}: no such recovery entry {name}: ऐसी कोई वसूली प्रविष्टि नहीं @msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: इस Affinity फ़ाइल का केवल एम्बेडेड प्रीव्यू पढ़ा जा सका; इसे इसकी चेतावनी के साथ देखने के लिए फ़ाइल › ओपन का उपयोग करें, या प्लेस करने से पहले Affinity से SVG या PDF निर्यात करें @@ -5981,16 +5981,16 @@ antiAlias Sharp शार्प @msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं -@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` होना चाहिए "none", "sharp", "crisp" या "strong" +@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` "none", "sharp", "crisp" या "strong" होना चाहिए @msg `artboards` must be a list of artboard indices `artboards` को आर्टबोर्ड इंडेक्स की सूची होना चाहिए @msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all या 1, 3-5 जैसी सीमा दें, {v} नहीं @msg `aspect` must be a number (%) `aspect` को संख्या (%) होना चाहिए @msg `asset` is the asset id (see assets.list) `asset` परिसंपत्ति आईडी है (assets.list देखें) @msg `at` must be [x, y] `at` होना चाहिए [x, y] -@msg `at` must be a byte offset `at` to be a byte ऑफसेट +@msg `at` must be a byte offset `at` बाइट ऑफ़सेट होना चाहिए @msg `autoLeading` must be a number `autoLeading` को संख्या होना चाहिए @msg `background` must be "", "matte" or #rrggbb `background` होना चाहिए "", "matte" या #rrggbb -@msg `burasagari` must be "none", "standard" or "forced" `burasagari` होना चाहिए "none", "standard" या "forced" +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` "none", "standard" या "forced" होना चाहिए @msg `by` must be lines, paragraphs, words or glyphs, not {other} `by` होना चाहिए लाइनों, पैराग्राफ, शब्द या glyphs, नहीं {other} @msg `center` must be [x, y] `center` होना चाहिए [x, y] @msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` को "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" या "icfBottom" होना चाहिए @@ -6031,15 +6031,15 @@ antiAlias Sharp शार्प @msg `digits` must be one of {_1} `digits` को {_1} में से एक होना चाहिए @msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या false है @msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "auto", "leftToRight" या "rightToLeft" होना चाहिए -@msg `dither` applies to gradient fills (paint.setFill) `dither` gradient fills (paint.setFill) पर लागू होता है +@msg `dither` applies to gradient fills (paint.setFill) `dither` ग्रेडिएंट फ़िल पर लागू होता है (paint.setFill) @msg `dither` applies to gradient fills (stroke: false) `dither` ग्रेडिएंट फ़िल (stroke: false) पर लागू होता है @msg `dither` must be true or false `dither` को true या false होना चाहिए @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए @msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। @msg `ends` applies to joining exactly two paths `ends` ठीक दो पाथ को जोड़ने पर लागू होता है -@msg `event` must be one of {_1} `event` is one of the {_1} -@msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} +@msg `event` must be one of {_1} `event` {_1} में से एक होना चाहिए +@msg `every` must be "all", "odd" or "even", not {v} `every` "all", "odd" या "even" होना चाहिए, {v} नहीं @msg `expand` must be true or false `expand` को true या false होना चाहिए @msg `exportPreset` is the name of a preset of document.exportPresets.list `exportPreset`, document.exportPresets.list के किसी प्रीसेट का नाम है @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए @@ -6143,11 +6143,11 @@ antiAlias Sharp शार्प @msg `preset` must be a string `preset` एक स्ट्रिंग होना चाहिए @msg `radius` must be from 0 to 1000000 pt `radius` 0 से 1000000 pt से होना चाहिए @msg `ratio` must be from 0.1 to 0.5 `ratio` को 0.1 से 0.5 तक होना चाहिए -@msg `rect` must be [x, y, width, height] with a positive size `rect` होना चाहिए [x, y, चौड़ाई, ऊंचाई] एक सकारात्मक आकार के साथ +@msg `rect` must be [x, y, width, height] with a positive size `rect` धनात्मक आकार वाला [x, y, width, height] होना चाहिए @msg `relation` must be overlap, inside or outside `relation` को ओवरलैप, अंदर या बाहर होना चाहिए @msg `reversed` must be true or false `reversed` को true या false होना चाहिए @msg `rotate` must be a finite number `rotate` एक परिमित संख्या होना चाहिए -@msg `rule` must be nonZero or evenOdd `rule` होना चाहिए nonZero or evenOdd होना चाहिए +@msg `rule` must be nonZero or evenOdd `rule` nonZero या evenOdd होना चाहिए @msg `sampleSize` must be 1, 3 or 5 `sampleSize` होना चाहिए 1, 3 या 5 @msg `scale` must be a number in (0, 100] `scale` को (0, 100] के बीच की संख्या होना चाहिए @msg `scope` must be "selection" or "document", not {other} `scope` को "selection" या "document" होना चाहिए, न कि {other} @@ -6162,7 +6162,7 @@ antiAlias Sharp शार्प @msg `size` is a percentage of the base text's size, 1 to 100 `size` आधार टेक्स्ट के आकार का एक प्रतिशत है, 1 से 100 @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @msg `source` must be `folder` or `datasets` `source` होना चाहिए `folder` या `datasets` होना चाहिए -@msg `spacing` must be a number `spacing` होना चाहिए +@msg `spacing` must be a number `spacing` एक संख्या होनी चाहिए @msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` पॉइंट में 0 या उससे बड़ी संख्या, या "auto" होनी चाहिए @msg `start` must be a finite number `start` को परिमित संख्या होना चाहिए @msg `starts` must be an array of anchor indices (or nulls) `starts` को एंकर इंडेक्स (या null) की ऐरे होना चाहिए @@ -6205,7 +6205,7 @@ antiAlias Sharp शार्प @msg `with` moves points, not a handle `with` पॉइंट मूव करता है, हैंडल नहीं @msg `with` must be a list of at most 10000 spine point indexes `with` में अधिकतम 10000 स्पाइन पॉइंट इंडेक्स की लिस्ट होनी चाहिए @msg a {_1} series can't go in a {_2} graph एक {_1} श्रृंखला एक {_2} ग्राफ में नहीं जा सकती -@msg a {width}×{height} screenshot's byte count overflows A {width}×{height} स्क्रीनशॉट की बाइट गिनती ओवरफ्लो +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} स्क्रीनशॉट की बाइट संख्या सीमा से बाहर है @msg a batch preview cannot switch documents एक बैच प्रीव्यू डॉक्यूमेंट्स को स्विच नहीं कर सकता @msg a bitmap is empty एक बिटमैप खाली है @msg a bitmap is too large to read बिटमैप पढ़ने के लिए बहुत बड़ा है @@ -6233,7 +6233,7 @@ antiAlias Sharp शार्प @msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes एक feConvolveMatrix फिल्टर का आदेश {x} ×{y} है, जो कर्नेल-आकार की बहुप्लाई usvg को ओवरफ्लो करता है। @msg a freeform gradient keeps at least one point फ़्रीफ़ॉर्म ग्रेडिएंट में कम से कम एक पॉइंट रहता है @msg a gradient needs at least two stops कम से कम दो स्टॉप की आवश्यकता होती है -@msg a gradient preset is already called {n} gradient preset पहले से ही कहा जाता है {n} +@msg a gradient preset is already called {n} {n} नाम का ग्रेडिएंट प्रीसेट पहले से मौजूद है @msg a graph has at most {_1} series, so it transposes with at most that many categories एक ग्राफ में सबसे {_1} श्रृंखला है, इसलिए यह उस कई श्रेणियों के साथ ट्रांसपोजित होता है। @msg a graph's records don't end where they should: its art was read as it is एक ग्राफ का रिकॉर्ड अंत नहीं है जहां उन्हें होना चाहिए: इसकी कला को पढ़ा गया क्योंकि यह है @msg a graphic style named `{to}` already exists `{to}` नामक एक ग्राफिक स्टाइल पहले से मौजूद है @@ -6259,7 +6259,7 @@ antiAlias Sharp शार्प @msg a page is missing एक पेज गायब हो रहा है @msg a page object is used by something else एक पृष्ठ ऑब्जेक्ट का उपयोग किसी अन्य चीज़ के द्वारा किया जाता है @msg a page out of range श्रेणी से बाहर एक पृष्ठ -@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) एक पृष्ठ थंबनेल RGB पिक्सल होना चाहिए, 1 to {THUMBNAIL_SIZE} a side (not {w} × {h}) +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) पेज थंबनेल RGB पिक्सल का होना चाहिए, हर तरफ़ 1 से {THUMBNAIL_SIZE} (मिला {w} × {h}) @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 एक पृष्ठ का प्लेसमेंट परिमित नहीं है @@ -6270,14 +6270,14 @@ antiAlias Sharp शार्प @msg a pattern named `{n}` exists `{n}` नामक एक पैटर्न मौजूद है @msg a PDF presets file with over {_1} presets {_1} प्रीसेट के साथ एक PDF प्रीसेट फ़ाइल @msg a plug-in with id {_1} is already installed आईडी {_1} के साथ एक प्लग-इन पहले से ही स्थापित है -@msg a PostScript error ({name} in `{op}`; {meaning}) एक पोस्टस्क्रिप्ट त्रुटि ({name} in `{op}`; {meaning}) -@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript त्रुटि ({name} in `{op}`; {meaning}), {procs} +@msg a PostScript error ({name} in `{op}`; {meaning}) पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}; {meaning}), {procs} में @msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript त्रुटि (`{op}` में {name}: {at}; {meaning}) @msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript त्रुटि (`{op}` में {name}: {at}; {meaning}), {procs} में -@msg a PostScript error ({name} in `{op}`: {at}) एक पोस्टस्क्रिप्ट त्रुटि ({name} in `{op}`: {at}) -@msg a PostScript error ({name} in `{op}`: {at}), in {procs} एक पोस्टस्क्रिप्ट त्रुटि ({name} in `{op}`: {at}), {procs} -@msg a PostScript error ({name} in `{op}`) पोस्टस्क्रिप्ट त्रुटि ({name} in `{op}`) -@msg a PostScript error ({name} in `{op}`), in {procs} PostScript त्रुटि ({name} in `{op}`), {procs} +@msg a PostScript error ({name} in `{op}`: {at}) पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}: {at}), {procs} में +@msg a PostScript error ({name} in `{op}`) पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}) +@msg a PostScript error ({name} in `{op}`), in {procs} पोस्टस्क्रिप्ट त्रुटि (`{op}` में {name}), {procs} में @msg a PostScript error ({name}; {meaning}) PostScript त्रुटि ({name}; {meaning}) @msg a PostScript error ({name}) पोस्टस्क्रिप्ट त्रुटि ({name}) @msg a preset named `{_1}` exists `{_1}` नामक एक प्रीसेट मौजूद है @@ -6293,7 +6293,7 @@ antiAlias Sharp शार्प @msg a start must be an anchor index ≥ 0 or null प्रारंभ में एक एंकर इंडेक्स होना चाहिए ≥ 0 या null @msg a stream runs past the end एक स्ट्रीम अंत तक चलता है @msg a stream's length is wrong धारा की लंबाई गलत है -@msg a stream's length isn't a number एक धारा की लंबाई नहीं है +@msg a stream's length isn't a number स्ट्रीम की लंबाई कोई संख्या नहीं है @msg a style named `{name}` already exists `{name}` नामक एक स्टाइल पहले से मौजूद है @msg a style named `{to}` already exists `{to}` नामक एक स्टाइल पहले से मौजूद है @msg a swatch needs a colour, gradient or pattern स्वॉच को रंग, ग्रेडिएंट या पैटर्न चाहिए @@ -6318,7 +6318,7 @@ antiAlias Sharp शार्प @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 विरासत .ai फ़ाइलों को लिखते हैं, .ait टेम्पलेट नहीं @msg All colors are within the CMYK gamut सभी रंग CMYK गामट के भीतर हैं @msg already editing a pattern पहले से ही एक पैटर्न संपादन -@msg already editing an opacity mask पहले से ही एक opacity मास्क संपादित करें +@msg already editing an opacity mask ओपेसिटी मास्क पहले से संपादित किया जा रहा है @msg already editing another object's opacity mask पहले से ही किसी अन्य ऑब्जेक्ट के अपारदर्शिता मास्क को संपादित करना @msg already in pattern editing mode पहले से ही पैटर्न संपादन मोड में @msg an action in the action set file has more than 10,000 steps एक्शन सेट फ़ाइल में एक कार्रवाई 10,000 से अधिक कदम है @@ -6338,7 +6338,7 @@ antiAlias Sharp शार्प @msg an image VectorCraft can't read was left out एक इमेज VectorCraft पढ़ा नहीं जा सकता था @msg an image whose data is cut short was left out जिस इमेज का डेटा छोटा है उसे छोड़ दिया गया था @msg an index must be a whole number एक सूचकांक होना चाहिए पूरी संख्या -@msg an inline graphic has no glyphs एक इनलाइन ग्राफ़िक में कोई glyph नहीं है +@msg an inline graphic has no glyphs इनलाइन ग्राफ़िक में कोई ग्लिफ़ नहीं है @msg an invisible text form can't be read एक अदृश्य टेक्स्ट फॉर्म नहीं पढ़ा जा सकता है @msg an object can't be read किसी ऑब्जेक्ट को पढ़ा नहीं जा सकता @msg an object in perspective would leave the edited grid पर्सपेक्टिव में एक ऑब्जेक्ट संपादित ग्रिड छोड़ देगी @@ -6352,37 +6352,37 @@ antiAlias Sharp शार्प @msg anchor `{key}` must be [x, y] एंकर `{key}` होना चाहिए [x, y] @msg anchor `{key}` must be a number एंकर `{key}` को एक नंबर होना चाहिए @msg anchor `smooth` must be true or false एंकर का `smooth` true या false होना चाहिए -@msg anchor must name a registered panel anchor: एंकर को एक पंजीकृत पैनल का नाम देना चाहिए +@msg anchor must name a registered panel anchor किसी पंजीकृत पैनल का नाम होना चाहिए @msg angle and roundness must be finite कोण और गोलाई परिमित होना चाहिए -@msg angles must be a list as long as pins (degrees or null) angles: कोण एक सूची होना चाहिए जब तक पिन (डिग्री या null) +@msg angles must be a list as long as pins (degrees or null) angles पिन जितनी लंबी सूची होनी चाहिए (डिग्री या null) @msg Angles require two straight segments कोणों को दो सीधे खंडों की आवश्यकता होती है @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art या type @msg appearance must be flat|center|edge appearance को flat|center|edge होना चाहिए @msg apply is "fill" or "stroke", not `{other}` apply "fill" या "stroke" है, नहीं `{other}` -@msg area must be a closed polygon of 3 to 100000 [x, y] points area: क्षेत्र 3 से 100000 [x, y] पॉइंट का एक बंद बहुभुज होना चाहिए +@msg area must be a closed polygon of 3 to 100000 [x, y] points area 3 से 100000 [x, y] पॉइंट का एक बंद बहुभुज होना चाहिए @msg area type {_1} is named twice एरिया टाइप {_1} का नाम दो बार दिया गया है @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise अपने फ़ॉन्ट की एक लाइन के लिए बहुत छोटे फ़्रेम वाले एरिया टाइप को लंबे फ़्रेम दिए गए ({_1}): वरना उनका टाइप नहीं दिखता @msg area type needs a path that encloses an area एरिया टाइप को ऐसा पाथ चाहिए जो किसी क्षेत्र को घेरता हो -@msg arrowAlign must be extend|tip, got {o} arrowAlign होना चाहिए thetip, get {o} +@msg arrowAlign must be extend|tip, got {o} arrowAlign को extend|tip होना चाहिए, {o} मिला @msg arrowScale must be [start, end] arrowScale होना चाहिए [शुरू, अंत] @msg artboard {0} does not exist आर्टबोर्ड {0} मौजूद नहीं है @msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है -@msg artboard is a 0-based index artboard: आर्टबोर्ड एक 0-आधारित इंडेक्स है -@msg artboard must be a 0-based index artboard: आर्टबोर्ड एक 0-आधारित इंडेक्स होना चाहिए +@msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है +@msg artboard must be a 0-based index artboard 0 से शुरू होने वाला इंडेक्स होना चाहिए @msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout को {layout?, columns?, spacing?, rightToLeft?} होना चाहिए @msg artboardLayout.columns must be 1 or more artboardLayout.columns को 1 या अधिक होना चाहिए @msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout को gridByRow, gridByColumn, row या column होना चाहिए @msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft को true या false होना चाहिए @msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing को 0–{MAX_SPACING} pt होना चाहिए -@msg artboards must be a non-empty list artboards: आर्टबोर्ड एक गैर-खाली सूची होना चाहिए -@msg artboards must be a number artboards: आर्टबोर्ड एक नंबर होना चाहिए +@msg artboards must be a non-empty list artboards एक गैर-खाली सूची होनी चाहिए +@msg artboards must be a number artboards एक संख्या होनी चाहिए @msg asset `{_1}` has no art to export संपत्ति `{_1}` को निर्यात करने के लिए कोई कला नहीं है @msg at most {MAX_KEYWORDS} keywords सबसे अधिक {MAX_KEYWORDS} कीवर्ड @msg at most {MAX_MATCHES} matches are kept अधिकांश {MAX_MATCHES} मैचों को रखा जाता है @msg at most {MAX_POINTS} points {MAX_POINTS} पॉइंट पर @msg at most {MAX_QUEUE} files सबसे अधिक {MAX_QUEUE} फ़ाइलों में @msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` समूहों के बाहर अधिकतम {MAX_UNGROUPED} पैरामीटर: बाकी को एक `group` दें -@msg at must be [x, y] at: [x, y] होना चाहिए +@msg at must be [x, y] at [x, y] होना चाहिए @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard इस पैमाने पर ड्राइंग {w} × {h} pt है, कैनवास से अधिक रखती है: एक छोटे पैमाने सिलेक्ट करें या इसे आर्टबोर्ड पर फिट करें @msg at/rect lie outside the canvas कैनवास के बाहर / सही झूठ @msg attribute must be size|leading|tracking|kerning|baselineShift attribute को size|leading|tracking|kerning|baselineShift होना चाहिए @@ -6401,8 +6401,8 @@ antiAlias Sharp शार्प @msg automation path rejected: path is empty स्वचालन पाथ खारिज: पाथ खाली है @msg automation path rejected: reserved device names are not allowed: {path} स्वचालन पाथ खारिज कर दिया: आरक्षित उपकरण नामों की अनुमति नहीं है: {path} @msg automation path rejected: the folder's name isn't valid text स्वचालन पाथ खारिज: फ़ोल्डर का नाम वैध टेक्स्ट नहीं है -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis: अक्ष को "vertical", "horizontal" या डिग्री में एक कोण होना चाहिए, नहीं `{given}`` -@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis: अक्ष को "vertical", "horizontal" या डिग्री में एक कोण होना चाहिए, नहीं `{v}` +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis "vertical", "horizontal" या डिग्री में कोण होना चाहिए, `{given}` नहीं +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis "vertical", "horizontal" या डिग्री में कोण होना चाहिए, `{v}` नहीं @msg background {other}: transparent, white, black or a colour such as "#ff8800" पृष्ठभूमि {other}: पारदर्शी, सफेद, काला या एक रंग जैसे "#ff8800" @msg backgroundContents must be transparent or white backgroundContents को transparent या white होना चाहिए @msg bad {k} colour बुरा {k} रंग @@ -6438,22 +6438,22 @@ antiAlias Sharp शार्प @msg bad stream length खराब धारा की लंबाई @msg bad trailer बुरा ट्रेलर @msg bad trailer size बुरा ट्रेलर आकार -@msg balance must be between 0 and 100 balance: 0 से 100 के बीच संतुलन होना चाहिए +@msg balance must be between 0 and 100 balance 0 से 100 के बीच होना चाहिए @msg batch step {_1} (`{id}`) failed: {e} बैच चरण {_1} (`{id}`) विफल: {e} @msg Batch: {count} processed बैच: {count} संसाधित @msg Batch: {count} processed, {failed} failed बैच: {count} संसाधित, {failed} विफल @msg batches cannot nest बैच घोंसला नहीं कर सकते -@msg before must name a panel before: पहले एक पैनल का नाम देना चाहिए +@msg before must name a panel before में किसी पैनल का नाम होना चाहिए @msg below and above must name a panel floating in another group नीचे और ऊपर एक पैनल दूसरे समूह में चल रहा है नाम होना चाहिए @msg bind something to a variable first पहले किसी ऑब्जेक्ट को वेरिएबल से बाँधें -@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed होना चाहिए 0-{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed 0–{MAX_BLEED} pt होना चाहिए: एक संख्या, [top, bottom, left, right] या {{top, bottom, left, right}} @msg Blend colors: select at least three objects with filled colours मिश्रित रंग: भरे हुए रंगों के साथ कम से कम तीन ऑब्जेक्ट चुनें @msg BMP files are RGB or grayscale: colorModel rgb or gray BMP फ़ाइलें RGB या ग्रेस्केल होती हैं: colorModel को rgb या gray रखें @msg brightness must be dark|mediumDark|mediumLight|light brightness को dark|mediumDark|mediumLight|light होना चाहिए -@msg brush must be a name or null brush: ब्रश एक नाम या null होना चाहिए +@msg brush must be a name or null brush एक नाम या null होना चाहिए @msg brushes move only within their type ब्रश केवल उनके प्रकार के भीतर ही चलते हैं @msg brushes VectorCraft can't read yet were left out of the Brushes panel ({_1}) ब्रश VectorCraft को अभी तक नहीं पढ़ा जा सकता है ब्रश पैनल ({_1}) से बाहर छोड़ दिया गया था। -@msg by must be a number by: द्वारा एक नंबर होना चाहिए +@msg by must be a number by एक संख्या होनी चाहिए @msg can't add page thumbnails: {why} पेज थंबनेल नहीं जोड़ा जा सकता: {why} @msg can't add the editing data for other apps: {why} अन्य ऐप्स के लिए संपादन डेटा नहीं जोड़ सकते: {why} @msg can't edit the written PDF: {why} लिखित PDF संपादित नहीं कर सकता: {why} @@ -6464,7 +6464,7 @@ antiAlias Sharp शार्प @msg can't open `{_1}`: {_2} `{_1}` नहीं खोल सकते: {_2} @msg can't open `{_1}`: {e} `{_1}` नहीं खोल सकते: {e} @msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` ओपन नहीं कर सकते: VectorCraft इस फ़ॉर्मैट को पढ़ नहीं सकता (document.formats देखें) -@msg can't parse shortcut `{c}` नहीं कर सकते हैं a shortcut `{c}` +@msg can't parse shortcut `{c}` शॉर्टकट `{c}` पार्स नहीं किया जा सका @msg can't parse shortcut `{s}` for `{k}` `{k}` के लिए शॉर्टकट `{s}` को पार नहीं कर सकता @msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` प्लेस नहीं कर सकते: VectorCraft इस फ़ॉर्मैट को पढ़ नहीं सकता (document.formats देखें) @msg can't read back the written PDF: {e} लिखित PDF को वापस नहीं पढ़ सकते: {e} @@ -6484,7 +6484,7 @@ antiAlias Sharp शार्प @msg cannot split an open path at its end point अपने अंतिम बिंदु पर एक खुले रास्ते को विभाजित नहीं कर सकता है @msg case must be upper|lower|title|sentence case को upper|lower|title|sentence होना चाहिए @msg category `{c}`: one of {_1} श्रेणी `{c}`: {_1} में से एक -@msg center must be [x, y] center: केंद्र होना चाहिए [x, y] +@msg center must be [x, y] center [x, y] होना चाहिए @msg check a layer to export निर्यात करने के लिए एक लेयर की जांच करें @msg check an artboard to export निर्यात करने के लिए एक आर्टबोर्ड की जांच करें @msg check an asset to export निर्यात करने के लिए एक परिसंपत्ति की जांच करें @@ -6508,8 +6508,8 @@ antiAlias Sharp शार्प @msg clip must be true or false clip को true या false होना चाहिए @msg clipboard is empty क्लिपबोर्ड खाली है @msg clippingMask must be true or false clippingMask को true या false होना चाहिए -@msg closed must be a boolean closed: बंद होना चाहिए एक boolean -@msg closing needs three anchors closing: बंद करने की जरूरत है तीन एंकर +@msg closed must be a boolean closed एक boolean होना चाहिए +@msg closing needs three anchors बंद करने के लिए तीन एंकर चाहिए @msg collapsed must be true or false collapsed को true या false होना चाहिए @msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: "{profile}" नाम की कोई RGB या CMYK प्रोफ़ाइल नहीं है @msg colorMode must be rgb or cmyk, not `{m}` colorMode को rgb या cmyk होना चाहिए, `{m}` नहीं @@ -6529,16 +6529,16 @@ antiAlias Sharp शार्प @msg Combine Slices: the slices have no bounds स्लाइस को मिलाएं: स्लाइस में कोई सीमा नहीं है @msg command `{0}` is not available right now: {1} आदेश `{0}` अभी उपलब्ध नहीं है: {1} @msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है -@msg command must be a shape.* command command: आदेश एक आकार होना चाहिए।* आदेश +@msg command must be a shape.* command command कोई shape.* कमांड होना चाहिए @msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command` -@msg composer must be "singleLine" or "everyLine" composer: संगीतकार "singleLine" या "everyLine" होना चाहिए। +@msg composer must be "singleLine" or "everyLine" composer "singleLine" या "everyLine" होना चाहिए @msg compound {_1} must contain exactly one open path to join जोड़ने के लिए कंपाउंड {_1} में ठीक एक खुला पाथ होना चाहिए @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं @msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है @msg connect end must be "start" or "end" कनेक्ट अंत "start" या "end" होना चाहिए -@msg connect needs a path other than the one extended connect: कनेक्ट एक विस्तारित के अलावा एक पाथ की जरूरत है -@msg connect needs id connect: कनेक्ट करने की जरूरत आईडी +@msg connect needs a path other than the one extended connect के लिए बढ़ाए गए पाथ के अलावा कोई दूसरा पाथ चाहिए +@msg connect needs id connect के लिए id चाहिए @msg connect path has no open end कनेक्ट पाथ का कोई खुला अंत नहीं है @msg Contour: select paths or shapes कंटूर: पाथ या आकृतियों सिलेक्ट करें @msg Contour: the Offset Path effect is not available Contour: ऑफसेट पाथ इफ़ेक्ट उपलब्ध नहीं है @@ -6651,8 +6651,8 @@ antiAlias Sharp शार्प @msg there is no script engine named {name} {name} नाम का कोई स्क्रिप्ट इंजन नहीं है @msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट -@msg copy needs path: the file to copy into the document's folder copy: प्रतिलिपि की जरूरत है पाथ: फ़ाइल को डॉक्यूमेंट के फ़ोल्डर में कॉपी करने के लिए -@msg copy takes images only: relink placed documents where their files are copy: प्रतिलिपि केवल इमेज को लेती है: डॉक्यूमेंट्स को फिर से लिंक करें जहां उनकी फ़ाइलें हैं +@msg copy needs path: the file to copy into the document's folder copy के लिए path चाहिए: डॉक्यूमेंट के फ़ोल्डर में कॉपी करने वाली फ़ाइल +@msg copy takes images only: relink placed documents where their files are copy केवल इमेज लेता है: प्लेस किए गए डॉक्यूमेंट्स को वहीं फिर से लिंक करें जहाँ उनकी फ़ाइलें हैं @msg copying a file isn't available on the web एक फ़ाइल की प्रतिलिपि वेब पर उपलब्ध नहीं है @msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus को unknown, copyrighted या publicDomain होना चाहिए @msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl को URL होना चाहिए (स्पेस नहीं, अधिकतम 2048 अक्षर) @@ -6660,7 +6660,7 @@ antiAlias Sharp शार्प @msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners में 4 [x, y] पॉइंट (TL, TR, BR, BL) होने चाहिए @msg could not decode image: {0} इमेज को डीकोड नहीं कर सकता: {0} @msg Couldn't copy to the system clipboard: {e} सिस्टम क्लिपबोर्ड को कॉपी नहीं कर सका: {e} -@msg Couldn't open {name}: {e} नहीं खोल सकता {name}: {e} +@msg Couldn't open {name}: {e} {name} ओपन नहीं हो सका: {e} @msg Couldn't paste from the clipboard: {e} क्लिपबोर्ड से पेस्ट नहीं कर सका: {e} @msg Couldn't paste: the document it was pasted in was closed पेस्ट नहीं कर सका: इसमें चिपकाए गए डॉक्यूमेंट को बंद कर दिया गया था @msg Couldn't place {name}: {e} नहीं रह सकता {name}: {e} @@ -6675,7 +6675,7 @@ antiAlias Sharp शार्प @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 Create needs fills, strokes or both जरूरतों को पूरा करने, स्ट्रोक या दोनों की जरूरत है +@msg Create needs fills, strokes or both बनाने के लिए फ़िल, स्ट्रोक या दोनों चाहिए @msg Create Outlines: select text objects आउटलाइन बनाएं: टेक्स्ट ऑब्जेक्ट चुनें @msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" क्रॉप `{_1}`: केवल PDF पेजों में आर्ट, ट्रिम, ब्लीड और मीडिया बॉक्स होते हैं; "crop" या "bounding" का उपयोग करें @msg crop rectangle doesn't overlap the image क्रॉप आयत इमेज को ओवरलैप नहीं करता @@ -6691,9 +6691,9 @@ antiAlias Sharp शार्प @msg damaged क्षतिग्रस्त @msg damaged compressed data: {e} क्षतिग्रस्त संकुचित डेटा: {e} @msg dash lengths must be numbers, got {v} डैश की लंबाई नंबर होना चाहिए, {v} होना चाहिए -@msg dash must be a list of lengths or null, got {v} dash: डैश लंबाई या null की एक सूची होना चाहिए, {v} मिला +@msg dash must be a list of lengths or null, got {v} dash लंबाइयों की सूची या null होना चाहिए, {v} मिला @msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) डेटा कुंजी `{k}`: अक्षर, अंक, -, _ और (नाम या vc-...) -@msg data must be an object of key: value, not {v} data: डेटा कुंजी का एक ऑब्जेक्ट होना चाहिए: मान, नहीं {v} +@msg data must be an object of key: value, not {v} data, key: value वाला ऑब्जेक्ट होना चाहिए, {v} नहीं @msg Data Recovery can't keep copies in {path}: {e} डेटा रिकवरी {path} में प्रतियां नहीं रख सकती: {e} @msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) डेटा रिकवरी की प्रतिलिपियाँ रखने के लिए कहीं नहीं है: निर्धारित प्राथमिकता → फ़ाइल हैंडलिंग → डेटा रिकवरी → फ़ोल्डर (recoveryFolder) @msg data set `{name}` holds a control character, which a variable library can't hold डेटा सेट `{name}` में एक कंट्रोल कैरेक्टर है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता @@ -6701,23 +6701,23 @@ antiAlias Sharp शार्प @msg data set `{name}` holds two values for `{var}` डेटा सेट `{name}` में `{var}` के लिए दो मान हैं। @msg data set `{name}` is declared twice डेटा सेट `{name}` को दो बार घोषित किया गया है @msg data set `{name}` starts or ends with white space, which a variable library can't hold डेटा सेट `{name}` की शुरुआत या अंत में व्हाइटस्पेस है, जिसे वेरिएबल लाइब्रेरी में नहीं रखा जा सकता -@msg data takes at most {MAX_DATA} keys at once data: डेटा एक बार में सबसे {MAX_DATA} कुंजी लेता है +@msg data takes at most {MAX_DATA} keys at once data एक बार में अधिकतम {MAX_DATA} कुंजियाँ लेता है @msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none प्रोग्राम पढ़ने वाले डेटा को क्षतिग्रस्त या छोटा काट दिया जाता है, या प्रोग्राम एक फ़ाइल में एक स्थिति के लिए पूछता है जिसमें कोई नहीं है। @msg dataset `{name}` already exists डेटासेट `{name}` पहले से मौजूद है @msg dataset `{to}` already exists डेटासेट `{to}` पहले से मौजूद है @msg date `{d}`: YYYY-MM-DD दिनांक `{d}`: YYYYY-MM-DD -@msg decimals must be {_1}–{_2} (got {_3}) decimals: दशमलव होना चाहिए {_1}-{_2} ({_3}) +@msg decimals must be {_1}–{_2} (got {_3}) decimals {_1}–{_2} होना चाहिए (मिला {_3}) @msg decoded data is too large डीकोडेड डेटा बहुत बड़ा है @msg define a variable first पहले एक वेरिएबल परिभाषित करें @msg detect must be true or false detect को true या false होना चाहिए -@msg Dimension is locked or hidden आयाम बंद या छिपा हुआ है +@msg Dimension is locked or hidden आयाम लॉक या छिपा हुआ है @msg Dimension mode does not match its source आयाम मोड अपने स्रोत से मेल नहीं खाता है @msg Dimension point is outside the canvas आयाम बिंदु कैनवास के बाहर है @msg Dimension size is outside its supported range आयाम आकार अपनी समर्थित सीमा से बाहर है @msg direct-select anchor points of a path एक पाथ के प्रत्यक्ष सिलेक्शन एंकर अंक @msg direct-select anchor points to cut at (not the end points of open paths) डायरेक्ट-सिलेक्शन एंकर पॉइंट्स को कट करने के लिए ( ओपन पाथ के अंतिम बिंदु नहीं) @msg direct-select the anchor points to remove हटाने के लिए एंकर पॉइंट्स सिलेक्ट करें -@msg distance must be > 0 distance: दूरी होना चाहिए > 0 +@msg distance must be > 0 distance > 0 होना चाहिए @msg divide into 1 to {MAX_PIECES} slices 1 से {MAX_PIECES} स्लाइस में विभाजित करें @msg Divide Objects Below: select a path नीचे दिए गए ऑब्जेक्ट्स को विभाजित करें: एक पाथ चुनें @msg Divide Objects Below: select exactly one path नीचे दिए गए ऑब्जेक्ट्स को विभाजित करें: बिल्कुल एक पाथ चुनें @@ -6728,12 +6728,12 @@ antiAlias Sharp शार्प @msg dx and dy must be numbers डीएक्स और डीआई होना चाहिए @msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} @msg dxf fitTo must be [width, height], positive dxf fitTo को धनात्मक [width, height] होना चाहिए -@msg dxf must be an object of DXF import options dxf: DXF को DXF आयात ऑप्शन्स का एक ऑब्जेक्ट होना चाहिए +@msg dxf must be an object of DXF import options dxf DXF आयात ऑप्शन्स का एक ऑब्जेक्ट होना चाहिए @msg DXF options: {e} DXF ऑप्शन्स: {e} -@msg dxf scale must be a positive number up to {_1}, not {s} Dxf स्केल को {_1} तक सकारात्मक संख्या होना चाहिए, न कि {s} -@msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल को {_1} तक सकारात्मक संख्या होना चाहिए, न कि {scale} +@msg dxf scale must be a positive number up to {_1}, not {s} dxf स्केल {_1} तक की धनात्मक संख्या होनी चाहिए, न कि {s} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल {_1} तक की धनात्मक संख्या होनी चाहिए, न कि {scale} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf इकाई `{u}`: एक इकाई जैसे mm, cm, in या pt -@msg dxf: {e} Dxf: {e} +@msg dxf: {e} dxf: {e} @msg each format is an object {format, scale?, suffix?} हर फ़ॉर्मैट एक ऑब्जेक्ट {format, scale?, suffix?} है @msg each item needs an id प्रत्येक आइटम को एक आईडी की आवश्यकता होती है @msg each match needs `kind` प्रत्येक मैच की जरूरत है `kind` @@ -6745,7 +6745,7 @@ antiAlias Sharp शार्प @msg each stop needs a position प्रत्येक स्टॉप को स्थिति की आवश्यकता होती है @msg each subpath needs an anchors array प्रत्येक उपपाथ को एक एंकर सरणी की आवश्यकता होती है @msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType को easeIn, easeOut, easeInOut या easeOutIn होना चाहिए -@msg Edit Original opens image and document files only; “{name}” is not one मूल रूप से केवल इमेज और डॉक्यूमेंट फ़ाइलों को खोलता है; "{name}" एक नहीं है +@msg Edit Original opens image and document files only; “{name}” is not one मूल एडिट करें केवल इमेज और डॉक्यूमेंट फ़ाइलें ओपन करता है; "{name}" इनमें से नहीं है @msg editingData must be true or false editingData को true या false होना चाहिए @msg editingData must be true, false or "always" editingData को true, false या "always" होना चाहिए @msg edits overlap ओवरलैप संपादित करें @@ -6755,10 +6755,10 @@ antiAlias Sharp शार्प @msg empty cutter खाली कटर @msg empty header in column {_1} कॉलम में खाली हैडर {_1} @msg empty path खाली पाथ -@msg encoding `{_1}`: utf8 or utf16 एन्कोडिंग `{_1}`: utf8 or utf16 -@msg ends must be two of "first" and "last" ends: समाप्त होता है दो "first" और "last" होना चाहिए। +@msg encoding `{_1}`: utf8 or utf16 एन्कोडिंग `{_1}`: utf8 या utf16 +@msg ends must be two of "first" and "last" ends में "first" और "last" में से दो मान होने चाहिए @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} -@msg EPS flattener: {e} ईपीएस flattener: {e} +@msg EPS flattener: {e} EPS फ़्लैटनर: {e} @msg EPS options: {e} ईपीएस ऑप्शन्स: {e} @msg EPS Save writes all artwork: use document.export for selected-only EPS EPS सेव पूरा आर्टवर्क लिखता है: केवल सिलेक्ट किए आर्टवर्क के EPS के लिए document.export उपयोग करें @msg every numbered name for {name} is taken in the document's folder {name} के लिए हर नंबर का नाम डॉक्यूमेंट के फ़ोल्डर में लिया जाता है @@ -6784,16 +6784,16 @@ antiAlias Sharp शार्प @msg Exporting {_1} निर्यात {_1} @msg exportText must be editable or appearance exportText को editable या appearance होना चाहिए @msg exportType must be movieClip or graphic, not `{s}` exportType को movieClip या graphic होना चाहिए, `{s}` नहीं -@msg extend needs id extend: विस्तार की जरूरत है आईडी +@msg extend needs id extend के लिए id चाहिए @msg extension {e} is listed twice एक्सटेंशन {e} को दो बार सूचीबद्ध किया गया है @msg extension {e}: use 1-16 lower-case letters and digits, without the dot एक्सटेंशन {e}: डॉट के बिना, 1-16 लोअर-केस अक्षरों और अंकों का उपयोग करें -@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges list at most {MAX_FEATURE_CHANGES} टैग +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges में अधिकतम {MAX_FEATURE_CHANGES} टैग होते हैं @msg featureChanges must be a list of tags featureChanges को टैग की सूची होना चाहिए @msg features lists at most {max} tags अधिकांश {max} टैग पर सूची -@msg features must be a list of tags features: विशेषताएं टैग की एक सूची होना चाहिए -@msg fidelity must be 0..100 fidelity: निष्ठा 0..100 होना चाहिए +@msg features must be a list of tags features टैग की एक सूची होनी चाहिए +@msg fidelity must be 0..100 fidelity 0..100 होना चाहिए @msg file version {0} is newer than this VectorCraft supports ({VERSION}) फ़ाइल संस्करण {0} इस वेक्टर क्रेफ्ट समर्थन ({VERSION}) की तुलना में नया है। -@msg files must be {name, dataBase64} files: फ़ाइलों को {name, dataBase64} होना चाहिए +@msg files must be {name, dataBase64} files {name, dataBase64} होना चाहिए @msg finish editing the pattern first पहले पैटर्न को संपादित करना @msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है @msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला @@ -6801,7 +6801,7 @@ antiAlias Sharp शार्प @msg Flat Logo traced the image at {_1} × {_2} pixels, averaged down from {_3} × {_4}. फ्लैट लोगो ने {_1} × {_2} पिक्सेल पर इमेज का पता लगाया, जो {_3} × {_4} से नीचे है। @msg Flatten Transparency: {e} Flatten transparency: {e} @msg Flatten Transparency: select objects to flatten Flatten Transparency: ऑब्जेक्ट्स को सपाट करने के लिए चुनें -@msg flattener: {e} Flattener: {e} +@msg flattener: {e} फ़्लैटनर: {e} @msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low या सेव किया हुआ प्रीसेट (flattener.presets.list देखें) @msg flipProfile must be along|across, got {o} flipProfile को along|across होना चाहिए, {o} मिला @msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item या itemFormat @@ -6810,42 +6810,42 @@ antiAlias Sharp शार्प @msg font family `{family}` is not available फ़ॉन्ट परिवार `{family}` उपलब्ध नहीं है @msg font family `{tf}` is not available फ़ॉन्ट परिवार `{tf}` उपलब्ध नहीं है @msg fonts not copied: {fonts} फ़ॉन्ट कॉपी नहीं हुए: {fonts} -@msg format `{f}`: pdf or postscript फ़ॉर्मैट `{f}`: pdf or postscript +@msg format `{f}`: pdf or postscript फ़ॉर्मैट `{f}`: pdf या postscript @msg format `{f}`: text or json फ़ॉर्मैट `{f}`: टेक्स्ट या json @msg format is `{format}` फ़ॉर्मैट `{format}` है -@msg formats must be an array of rows, not {v} formats: फ़ॉर्मैट पंक्तियों की एक सरणी होना चाहिए, न कि {v} +@msg formats must be an array of rows, not {v} formats पंक्तियों की ऐरे होनी चाहिए, {v} नहीं @msg freeform line {i} must be an array of point indices फ्रीफॉर्म लाइन {i} को पॉइंट इंडेक्स की एक सरणी होना चाहिए @msg freeform point {i} needs `at` [x, y] and a valid `color` फ्रीफॉर्म पॉइंट {i} को `at` [x, y] और एक वैध `color` की आवश्यकता होती है -@msg frequency must be between 1 and 300 lines per inch frequency: आवृत्ति प्रति इंच 1 और 300 लाइनों के बीच होना चाहिए +@msg frequency must be between 1 and 300 lines per inch frequency प्रति इंच 1 से 300 लाइनों के बीच होनी चाहिए @msg from and to must be finite से और होना चाहिए करने के लिए -@msg from must be [x0, y0, x1, y1] from: से होना चाहिए [x0, y0, x1, y1] -@msg give {key, value} or {values} {key, value} or {values} -@msg give `{a}` and `{b}` देना `{a}` and `{b}` +@msg from must be [x0, y0, x1, y1] from [x0, y0, x1, y1] होना चाहिए +@msg give {key, value} or {values} {key, value} या {values} दें +@msg give `{a}` and `{b}` `{a}` और `{b}` दें @msg give `anchor`, the index of the anchor to slide खिसकाने वाले एंकर का इंडेक्स `anchor` दें @msg give `before` or `after`, not both `before` या `after` दें, दोनों नहीं -@msg give `char` or `text` देना `char` or `text` +@msg give `char` or `text` `char` या `text` दें @msg give `colors`, or `color` and `rule` देना `colors`, या `color` और `rule` @msg give `event`, such as onclick देना `event`, जैसे onclick @msg give `file`, the JavaScript file's path or URL देना `file`, जावास्क्रिप्ट फ़ाइल का पाथ या यूआरएल -@msg give `fill` and/or `stroke` `fill` और/or`stroke` +@msg give `fill` and/or `stroke` `fill` और/या `stroke` दें @msg give `fill`, `stroke` or both `fill`, `stroke` या दोनों @msg give `folder` देना `folder` @msg give `fonts`, or open a document देना `fonts`, या एक डॉक्यूमेंट ओपन करें @msg give `from` or `indices`, not both `from` या `indices` दें, दोनों नहीं @msg give `id` (no text is being edited) देना `id` (कोई टेक्स्ट संपादित नहीं किया जा रहा है) @msg give `id` and `anchors`, or `items`, not both देना `id` और `anchors`, या `items`, दोनों नहीं -@msg give `id` or `guide`, not both देना `id` or `guide`, दोनों नहीं -@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` देना `inkLimit`, `removeWhiteOverprint` or `overprintBlack` +@msg give `id` or `guide`, not both `id` या `guide` दें, दोनों नहीं +@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` या `overprintBlack` दें @msg give `length` or `to` `length` या `to` देना @msg give `matches`: [{kind, name, target}] `matches`: [{kind, name, target}] @msg give `name` (or edit a pattern) `name` (या एक पैटर्न संपादित करें) @msg give `name` or `names` `name` या `names` दें -@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (a .wasm file) या `dataBase64` (module) +@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm फ़ाइल) या `dataBase64` (मॉड्यूल) दें @msg give `path`, `data` or `dataBase64` देना `path`, `data` या `dataBase64` @msg give `path`, `data`, `dataBase64` or `csv` देना `path`, `data`, `dataBase64` या `csv` @msg give `path`, `xml` or `dataBase64` देना `path`, `xml` या `dataBase64` @msg give `script`, the JavaScript the event runs ("" removes the event) देना `script`, जावास्क्रिप्ट घटना रन (""" घटना को हटा देता है) -@msg give `text` (the reading) or `remove` `text` (remove`) या `remove` +@msg give `text` (the reading) or `remove` `text` (रीडिंग) या `remove` दें @msg give `width` or `height` `width` या `height` दें @msg give `x` and `y` (and a `tolerance` of 0 or more) `x` और `y` दें (और 0 या अधिक `tolerance`) @msg give a fraction `t` from 0 to 1 0 से 1 तक एक अंश `t` देना @@ -6875,7 +6875,7 @@ antiAlias Sharp शार्प @msg give formats or a preset, not both फ़ॉर्मैट या एक पूर्व निर्धारित देना, दोनों ही नहीं @msg give horizontal or vertical क्षैतिज या ऊर्ध्वाधर देना @msg give newName or delete newName या delete दें -@msg give offset or by ऑफसेट देना या by +@msg give offset or by offset या by दें @msg give one of `to`, `rect` or `at` `to`, `rect` या `at` में से एक देना @msg give one of assets, layers, selection or fullDocument assets, layers, selection या fullDocument में से एक दें @msg give only one of onto, above, below, icon and a column place केवल ऊपर, नीचे, आइकन और एक स्तंभ स्थान देने के लिए @@ -6905,17 +6905,17 @@ antiAlias Sharp शार्प @msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) Glyph {_1} चरित्र की स्टाइल (जैसे सभी कैप्स, छोटे कैप्स या एक संख्यात्मक प्रणाली) के साथ {ch} के लिए तैयार नहीं है। @msg glyph {g} is not an alternate of {ch} in {_1} {_2} Glyph {g} {_1} {_2} में {ch} का एक वैकल्पिक नहीं है। @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है -@msg gradient must be "objects" or "mesh" gradient: ग्रेडिएंट "objects" या "mesh" होना चाहिए +@msg gradient must be "objects" or "mesh" gradient "objects" या "mesh" होना चाहिए @msg graphs come in as plain art, without their data or settings ग्राफ़ अपने डेटा या सेटिंग्स के बिना सादे कला के रूप में आते हैं -@msg grays must be "profile" or "black", not `{g}` grays: ग्रे "profile" या "black" होना चाहिए, नहीं `{g}` +@msg grays must be "profile" or "black", not `{g}` grays "profile" या "black" होना चाहिए, `{g}` नहीं @msg gridColors must be a preset name or two colours gridColors को प्रीसेट नाम या दो रंग होना चाहिए @msg gridSize must be small, medium or large gridSize को small, medium या large होना चाहिए -@msg group must be the id of a Shaper Group group: समूह को एक शेपर ग्रुप का आईडी होना चाहिए +@msg group must be the id of a Shaper Group group किसी शेपर ग्रुप की id होनी चाहिए @msg group must be true or false group को true या false होना चाहिए @msg guide {g} is not selected गाइड {g} नहीं चुना गया है @msg guides are locked गाइड लॉक कर रहे हैं @msg handle must be 0..3 handle को 0..3 होना चाहिए -@msg handle must be in or out handle: संभालना चाहिए या बाहर होना चाहिए +@msg handle must be in or out handle in या out होना चाहिए @msg heightWidth must be from 1 to 1000 % heightWidth को 1 से 1000 % होना चाहिए @msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (डिफ़ॉल्ट), "omit" या "visible" @msg highlight a layer to merge into में विलय करने के लिए एक लेयर को हाइलाइट करें @@ -6927,7 +6927,7 @@ antiAlias Sharp शार्प @msg icon only docks a panel in the icon column, with no anchor आइकन केवल आइकन कॉलम में एक पैनल को डॉक करता है, जिसमें कोई एंकर नहीं होता है @msg id {value} is a layer, not an object Id {value} एक लेयर है, कोई ऑब्जेक्ट नहीं है @msg id {value} is a layer, not an object; move layers with layer.move id {value} एक लेयर है, ऑब्जेक्ट नहीं; लेयर्स को layer.move से स्थानांतरित करें -@msg id must be a dimension id id: आईडी एक आयाम आईडी होना चाहिए +@msg id must be a dimension id id किसी आयाम की id होनी चाहिए @msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) ऑब्जेक्ट्स को XML ID से पहचानें: नाम एक वैध XML ID होना चाहिए (एक पत्र, अंडरस्कोर या कॉलोन के साथ शुरू; कोई स्थान नहीं) @msg ids must contain 1–10000 object ids ids में 1–10000 ऑब्जेक्ट ids होना चाहिए @msg image '{key}' has no pixels and was left out इमेज '{key}' में कोई पिक्सेल नहीं है और इसे छोड़ दिया गया था @@ -6945,25 +6945,25 @@ antiAlias Sharp शार्प @msg Imported {what}: {_1} आयात {what}: {_1} @msg Imported library {name} आयातित लाइब्रेरी {name} @msg included must be true or false included को true या false होना चाहिए -@msg index must be ≥ 0 index: सूचकांक होना चाहिए ≥ 0 -@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 index must be ≥ 0 index ≥ 0 होना चाहिए +@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 index out of range रेंज से बाहर सूचकांक @msg Installed plug-in {_1} स्थापित प्लग-इन {_1} @msg installing from a path is not available on the web; pass the module as `dataBase64` एक पाथ से स्थापित वेब पर उपलब्ध नहीं है; मॉड्यूल को `dataBase64`` के रूप में पास करें -@msg instances must be 1..1000 instances: उदाहरण 1..1000 +@msg instances must be 1..1000 instances 1..1000 होना चाहिए @msg instruction budget निर्देश बजट -@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` में आंतरिक त्रुटि: {msg} (लेखन पहले ही छोड़ दिया गया था); कृपया इस बग की रिपोर्ट करें) +@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 Internal error: {msg} (please report this bug) आंतरिक त्रुटि: {msg} (कृपया इस बग की रिपोर्ट करें) @msg invalid `color` अमान्य `color` @msg invalid anchor index {v}: expected a non-negative integer अमान्य एंकर इंडेक्स {v}: एक गैर-नकारात्मक पूर्णांक की उम्मीद @msg invalid anchor reference {v}: expected [subpath, anchor] अमान्य एंकर संदर्भ {v}: उम्मीद [subpath, एंकर] -@msg invalid angle अमानवीय कोण +@msg invalid angle अमान्य कोण @msg invalid brush: {e} अमान्य ब्रश: {e} @msg invalid color अमान्य रंग @msg invalid colour {c} अमान्य रंग {c} -@msg invalid delta अवैध डेल्टा +@msg invalid delta अमान्य डेल्टा @msg Invalid dimension distance अमान्य आयाम दूरी @msg Invalid dimension font name अमान्य आयाम फ़ॉन्ट नाम @msg Invalid dimension offset अमान्य आयाम ऑफसेट @@ -6977,16 +6977,16 @@ antiAlias Sharp शार्प @msg invalid parameter name {k} अमान्य पैरामीटर नाम {k} @msg invalid parameters for `{cmd}`: {msg} `{cmd}` के लिए अमान्य पैरामीटर: {msg} @msg invalid pattern tile choice अमान्य पैटर्न टाइल ऑप्शन -@msg invalid PDF setting: {0} Invalid PDF सेटिंग: {0} +@msg invalid PDF setting: {0} अमान्य PDF सेटिंग: {0} @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं @msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: {label} पेज बिना UserUnit के, पॉइंट में लिखे जाते हैं, और सीमा मानने वाले ऐप पेज को 200 in पर काट देते हैं @msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को अपना आकार बनाए रखने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है @msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को PDF/A-2 की 14,400 इकाइयों की पेज सीमा के भीतर रहने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है @msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units आर्टबोर्ड {n} का पेज {size} है, जो PDF की 3 से 14,400 इकाई की पेज भुजाओं के लिए बहुत लंबा और संकरा है: इसे {unit} की UserUnit के साथ लिखा जाता है, जो छोटी भुजा को 3 इकाई या अधिक पर रखती है, और सीमा का पालन करने वाले ऐप लंबी भुजा को 14,400 इकाई पर काट देते हैं @msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} फ़ाइलें {max} तक की वास्तविक संख्याएँ रखती हैं, और आर्टबोर्ड {n} का {side} pt लंबा पेज इससे बड़ी संख्याएँ माँगता है: बिना PDF/X मानक के PDF 1.6 या बाद का चुनें, या छोटा आर्टबोर्ड -@msg invalid point अमानवीय बिंदु -@msg Invalid point अमानवीय बिंदु -@msg Invalid real scale अमानवीय वास्तविक पैमाने +@msg invalid point अमान्य बिंदु +@msg Invalid point अमान्य बिंदु +@msg Invalid real scale अमान्य वास्तविक स्केल @msg invalid restored panel group अमान्य बहाल पैनल समूह @msg invalid size अमान्य आकार @msg invalid stroke widths अमान्य स्ट्रोक चौड़ाई @@ -7014,8 +7014,8 @@ antiAlias Sharp शार्प @msg its editing data doesn't say where its artboard is इसके संपादन डेटा का कहना नहीं है कि इसका आर्टबोर्ड कहां है? @msg its editing data has no layers इसके संपादन डेटा में कोई लेयरें नहीं हैं @msg its editing data is damaged इसके संपादन डेटा क्षतिग्रस्त हो जाता है -@msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page इसके संपादन डेटा एक {w} × {h} pt page के लिए है, फ़ाइल का {pw} × {ph} pt page -@msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} इसके संपादन डेटा एक {w} × {h} pt page के लिए है, फ़ाइल का {pw} × {ph} pt page {scale} पर +@msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page इसका संपादन डेटा {w} × {h} pt के पेज के लिए है, फ़ाइल के {pw} × {ph} pt के पेज के लिए नहीं +@msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} इसका संपादन डेटा {w} × {h} pt के पेज के लिए है; फ़ाइल का पेज {scale} पर {pw} × {ph} pt है @msg its editing data is for a different page इसके संपादन डेटा एक अलग पृष्ठ के लिए है @msg its layers use {_1}, which aren't read yet इसकी लेयरें {_1} का उपयोग करती हैं, जो अभी तक पढ़ा नहीं जाता है @msg its metadata has no description इसके मेटाडाटा में कोई विवरण नहीं है @@ -7032,26 +7032,26 @@ antiAlias Sharp शार्प @msg join two different paths दो अलग पाथ जोड़ें @msg Join: a subpath went missing जोड़ते समय एक सबपाथ नहीं मिला @msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए -@msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps} +@msg kbps must be a positive number, not {kbps} kbps एक धनात्मक संख्या होनी चाहिए, {kbps} नहीं @msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए -@msg keywords must be a list of words or a comma-separated string keywords: कीवर्ड शब्दों की एक सूची होना चाहिए या एक अल्पसंख्यक स्ट्रिंग होना चाहिए -@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: कीवर्ड नियंत्रण वर्णों के बिना, अधिकांश {MAX_KEYWORD} वर्णों पर होना चाहिए +@msg keywords must be a list of words or a comma-separated string keywords शब्दों की सूची या कॉमा से अलग की गई स्ट्रिंग होनी चाहिए +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords अधिकतम {MAX_KEYWORD} वर्णों के होने चाहिए, कंट्रोल वर्णों के बिना @msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind 1, 2 या 3 होना चाहिए, या प्रीसेट `name` दें (perspective.presets.list देखें) @msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind को graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle या paraStyle होना चाहिए -@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "neutral" या एक बोल, न {v} होना चाहिए -@msg lang must be one of {_1} lang: लैंग को {_1} में से एक होना चाहिए +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout "on", "off", "neutral" या bool होना चाहिए, {v} नहीं +@msg lang must be one of {_1} lang {_1} में से एक होना चाहिए @msg larger than 256 MB 256 MB से अधिक @msg layer `{name}` has no art to export लेयर `{name}` को निर्यात करने के लिए कोई कला नहीं है -@msg layer must name a layer or sublayer layer: लेयर को एक लेयर या सबलेयर नाम देना चाहिए +@msg layer must name a layer or sublayer layer में किसी लेयर या सबलेयर का नाम होना चाहिए @msg layers can't be intertwined: select the objects in them लेयर्स में हस्तक्षेप नहीं किया जा सकता है: उनमें ऑब्जेक्ट्स सिलेक्ट करें @msg layers can't be wrap objects लेयर्स को लपेटा नहीं जा सकता @msg layers go only at the top level or in layers, objects in layers and groups लेयरें केवल शीर्ष स्तर पर या लेयर्स में, लेयर्स और समूहों में ऑब्जेक्टएं @msg layers must be true or false layers को true या false होना चाहिए @msg layout must be gridByRow, gridByColumn, row or column layout को gridByRow, gridByColumn, row या column होना चाहिए -@msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "auto" होना चाहिए +@msg leading must be a number or "auto" leading कोई संख्या या "auto" होना चाहिए @msg left/right must be non-negative widths बाएं / दाएं गैर-नकारात्मक चौड़ाई होना चाहिए @msg level `{t}`: 2 or 3 स्तर `{t}`: 2 या 3 -@msg library panel `{given}` isn't missing its library लाइब्रेरी पैनल `{given}` अपने लाइब्रेरी को याद नहीं कर रहा है +@msg library panel `{given}` isn't missing its library लाइब्रेरी पैनल `{given}` की लाइब्रेरी गुम नहीं है @msg library panel `{given}` isn't open लाइब्रेरी पैनल `{given}` खुला नहीं है @msg line {line}: more than {MAX_CSV_COLS} columns लाइन {line}: {MAX_CSV_COLS} कॉलम से अधिक @msg line {line}: unclosed quote लाइन {line}: unclosed उद्धरण @@ -7076,7 +7076,7 @@ antiAlias Sharp शार्प @msg manifest: {e} प्रकटन: {e} @msg margin: a {m} pt margin leaves no imageable area on the paper मार्जिन: एक {m} pt मार्जिन कागज पर कोई इमेजेबल क्षेत्र नहीं छोड़ता है @msg mask art ends outside a mask dictionary मास्क कला एक मास्क शब्दकोश के बाहर समाप्त होता है -@msg matrix must be finite and invertible, depth finite matrix: मैट्रिक्स को परिमित और उलटा, गहराई परिमित होना चाहिए +@msg matrix must be finite and invertible, depth finite matrix परिमित और व्युत्क्रमणीय होनी चाहिए, और depth परिमित @msg matte: {e} मैट: {e} @msg Measured anchor no longer exists मापने वाला एंकर अब मौजूद नहीं है @msg Invalid measured point मापा गया बिंदु अमान्य है @@ -7098,13 +7098,13 @@ antiAlias Sharp शार्प @msg missing `family` `family` अनुपस्थित है @msg missing `find` लापता `find` @msg missing `folder` लापता `folder` -@msg missing `gid` (a glyph id, or null for the default glyph) लापता `gid` (a glyph id, या डिफ़ॉल्ट glyph के लिए null) +@msg missing `gid` (a glyph id, or null for the default glyph) लापता `gid` (ग्लिफ़ id, या डिफ़ॉल्ट ग्लिफ़ के लिए null) @msg missing `group` लापता `group` @msg missing `id` लापता `id` @msg missing `id` (see plugin.list) अनुपलब्ध `id` (plugin.list देखें) @msg missing `id`: give one, or select one type object लापता `id`: एक दें, या एक टाइप ऑब्जेक्ट सिलेक्ट करें -@msg missing `index` (a point index, or null to clear) लापता `index` (a बिंदु सूचकांक, या स्पष्ट करने के लिए null) -@msg missing `index` (a stop index, or null to clear) लापता `index` (a स्टॉप इंडेक्स, या स्पष्ट करने के लिए null) +@msg missing `index` (a point index, or null to clear) लापता `index` (बिंदु इंडेक्स, या हटाने के लिए null) +@msg missing `index` (a stop index, or null to clear) लापता `index` (स्टॉप इंडेक्स, या हटाने के लिए null) @msg missing `index` (an item index or null) लापता `index` (एक आइटम सूचकांक या null) @msg missing `item` लापता `item` @msg missing `kind` लापता `kind` @@ -7116,7 +7116,7 @@ antiAlias Sharp शार्प @msg missing `map` (rows [{from, to}] or {current}) लापता `map` (rows [{from, to}] या {current}) @msg missing `matrix` [a,b,c,d,e,f] लापता `matrix` [a,b,c,d,e,f] @msg missing `name` लापता `name` -@msg missing `name` (see gradient.presets.list) लापता `name` (view gradient.presets.list) +@msg missing `name` (see gradient.presets.list) लापता `name` (gradient.presets.list देखें) @msg missing `offset` (a distance in points) लापता `offset` (बिंदुओं में एक दूरी) @msg missing `path` लापता `path` @msg missing `path` (a path object id) लापता `path` (एक पाथ ऑब्जेक्ट आईडी) @@ -7127,7 +7127,7 @@ antiAlias Sharp शार्प @msg missing `target` id लापता `target` id @msg missing `text` लापता `text` @msg missing `to` `to` अनुपस्थित है -@msg missing `to` (an index) लापता `to` (an index) +@msg missing `to` (an index) लापता `to` (एक इंडेक्स) @msg missing `to`: a non-negative integer `to` अनुपस्थित है: एक गैर-ऋणात्मक पूर्णांक चाहिए @msg missing `tool` लापता `tool` @msg missing `type` लापता `type` @@ -7164,11 +7164,11 @@ antiAlias Sharp शार्प @msg missing stops लापता ठहराव @msg missing subpaths लापता subpaths @msg missing target लापता लक्ष्य -@msg missing to याद करना -@msg missing x, y लापता xy +@msg missing to लापता to +@msg missing x, y लापता x, y @msg mode is set, add or toggle, not `{other}` mode set, add या toggle है, `{other}` नहीं -@msg mode must be "cmyk" or "rgb" mode: मोड "cmyk" या "rgb" होना चाहिए -@msg mode must be area|onPath, got {o} mode: मोड होना चाहिए क्षेत्रफल (onPath), {o} मिला +@msg mode must be "cmyk" or "rgb" mode "cmyk" या "rgb" होना चाहिए +@msg mode must be area|onPath, got {o} mode को area|onPath होना चाहिए, {o} मिला @msg module is {_1} bytes (limit {_2}) मॉड्यूल {_1} बाइट्स का है (सीमा {_2}) @msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES} डेटा सेट से अधिक @msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES} फ़ाइलों से अधिक @@ -7181,22 +7181,22 @@ antiAlias Sharp शार्प @msg more than 256 commands 256 से अधिक कमांड @msg more than one action is named “{_1}” एक से अधिक कार्रवाई का नाम "{_1}" रखा गया है। @msg Move Panel to New Window is not available: floating panels stay in the main window नई विंडो में पैनल को स्थानांतरित करना उपलब्ध नहीं है: अस्थायी पैनल मुख्य विंडो में रहते हैं -@msg move requires a destination panel move: एक गंतव्य पैनल की आवश्यकता होती है -@msg move requires an existing destination panel move: चाल एक मौजूदा गंतव्य पैनल की आवश्यकता है +@msg move requires a destination panel move के लिए गंतव्य पैनल चाहिए +@msg move requires an existing destination panel move के लिए मौजूदा गंतव्य पैनल चाहिए @msg moved must be [[x,y]…] moved को [[x,y]…] होना चाहिए @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode मल्टीचैनल PSD फ़ाइलों को पढ़ा नहीं जा सकता: इसे RGB, CMYK या ग्रेस्केल मोड में सेव करें @msg name (a toolbar's name) is required नाम (एक टूलबार का नाम) की आवश्यकता होती है @msg name (a toolbar's name) must be a string नाम (एक टूलबार का नाम) एक स्ट्रिंग होना चाहिए @msg name is empty नाम खाली है -@msg name must be a toolbar's name name: नाम एक टूलबार का नाम होना चाहिए +@msg name must be a toolbar's name name किसी टूलबार का नाम होना चाहिए @msg name the file फ़ाइल नाम @msg names is empty names खाली है -@msg names must be an array of symbol names names: नाम सिंबल नामों की एक सरणी होना चाहिए +@msg names must be an array of symbol names names सिंबल नामों की एक सरणी होनी चाहिए @msg need `anchor` or `segment` + `t` की जरूरत `anchor` या `segment` + `t` @msg need at least one anchor कम से कम एक एंकर की जरूरत है @msg need at least one point कम से कम एक बिंदु की जरूरत है @msg need at least two points कम से कम दो बिंदुओं की आवश्यकता -@msg needs points जरूरत अंक +@msg needs points पॉइंट्स चाहिए @msg New Window isn't available yet: a document opens in one window नई विंडो अभी तक उपलब्ध नहीं है: एक डॉक्यूमेंट एक विंडो में खुलता है @msg nineSlice must be true or false nineSlice को true या false होना चाहिए @msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides को [left, top, right, bottom] होना चाहिए, हर मान -16..16, और left + right < 1 तथा top + bottom < 1 @@ -7272,7 +7272,7 @@ antiAlias Sharp शार्प @msg no formats to export निर्यात करने के लिए कोई फ़ॉर्मैट नहीं @msg no free data set file name in destination folder गंतव्य फ़ोल्डर में कोई मुफ्त डेटा सेट फ़ाइल नाम नहीं @msg no free recovery area कोई मुफ्त वसूली क्षेत्र -@msg no gradient preset `{name}` (see gradient.presets.list) कोई gradient preset `{name}` (view gradient.presets.list) +@msg no gradient preset `{name}` (see gradient.presets.list) `{name}` नाम का कोई ग्रेडिएंट प्रीसेट नहीं (gradient.presets.list देखें) @msg no gradient stop or freeform point is selected कोई क्रमिक स्टॉप या फ्रीफॉर्म पॉइंट नहीं चुना जाता है @msg no gradient swatch `{name}` कोई ग्रेडिएंट स्वॉच `{name}` नहीं @msg no graphic style `{n}` कोई ग्राफिक स्टाइल `{n}` @@ -7281,7 +7281,7 @@ antiAlias Sharp शार्प @msg no graphic style library `{key}` (see graphicStyle.libraries) कोई ग्राफिक स्टाइल लाइब्रेरी `{key}` (graphicStyle.libraries देखें) @msg no guide {i} कोई गाइड {i} @msg no guide selected कोई गाइड सिलेक्टेड नहीं -@msg no guide selected (or give an index) कोई गाइड नहीं चुना गया (या एक सूचकांक देना) +@msg no guide selected (or give an index) कोई गाइड सिलेक्ट नहीं है (या index दें) @msg no history to clear स्पष्ट करने के लिए कोई इतिहास नहीं @msg no history state {v} कोई हिस्ट्री स्थिति {v} नहीं @msg no images in the folder फ़ोल्डर में कोई इमेज नहीं @@ -7322,7 +7322,7 @@ antiAlias Sharp शार्प @msg no plug-in {id} is installed कोई प्लग-इन {id} स्थापित नहीं है @msg no PNG encoder नहीं पीएनजी एनकोडर @msg no point {i} (the gradient has {_1}) कोई बिंदु {i} (ग्रेडेंट {_1} है) -@msg no point selected (give `index`) कोई बिंदु नहीं चुना गया (give `index`) +@msg no point selected (give `index`) कोई बिंदु सिलेक्ट नहीं है (`index` दें) @msg no preset called `{from}` (see file.newPresets) `{from}` नामक कोई प्रीसेट नहीं ( file.newPresets देखें) @msg no preset called `{name}` (see file.newPresets) `{name}` नामक कोई पूर्व निर्धारित नहीं है (file.newPresets देखें) @msg no preset editor कोई पूर्व निर्धारित संपादक @@ -7332,7 +7332,7 @@ antiAlias Sharp शार्प @msg no print preset named `{base}` `{base}` नामक कोई प्रिंट प्रीसेट नहीं है @msg no print preset named `{n}` `{n}` नामक कोई प्रिंट प्रीसेट नहीं है @msg no print preset named `{name}` कोई प्रिंट प्रीसेट जिसका नाम `{name}` है -@msg no print preset named `{name}` (presets: {_1}) `{name}` नामक कोई प्रिंट प्रीसेट नहीं (presets: {_1}) +@msg no print preset named `{name}` (presets: {_1}) `{name}` नाम का कोई प्रिंट प्रीसेट नहीं (प्रीसेट: {_1}) @msg no print service कोई प्रिंट सेवा नहीं @msg no profile named `{name}` `{name}` नामक कोई प्रोफ़ाइल नहीं @msg no readable files in the folder फ़ोल्डर में कोई पठनीय फ़ाइल नहीं @@ -7396,7 +7396,7 @@ antiAlias Sharp शार्प @msg no such rows ऐसी कोई पंक्ति नहीं @msg no such saved selection ऐसा कोई सेव किया गया सिलेक्शन नहीं @msg no such segment ऐसा कोई खंड नहीं -@msg no such spine point ऐसी कोई रीढ़ की हड्डी नहीं +@msg no such spine point ऐसा कोई स्पाइन बिंदु नहीं @msg no such spine segment ऐसा कोई खंड नहीं @msg no such subpath ऐसा कोई उप-पाथ नहीं @msg no such user library ऐसी कोई उपयोगकर्ता लाइब्रेरी नहीं @@ -7419,12 +7419,12 @@ antiAlias Sharp शार्प @msg no symbol library `{key}` (see symbol.libraries) `{key}` सिंबल लाइब्रेरी नहीं है (symbol.libraries देखें) @msg no symbol named `{n}` `{n}` नाम का कोई सिंबल नहीं @msg no symbol named `{name}` `{name}` नाम का कोई सिंबल नहीं -@msg no target object has an opacity mask: select one or give `id` कोई लक्ष्य ऑब्जेक्ट एक opacity मास्क नहीं है: एक सिलेक्ट करें या `id` देना +@msg no target object has an opacity mask: select one or give `id` किसी लक्ष्य ऑब्जेक्ट में ओपेसिटी मास्क नहीं है: एक सिलेक्ट करें या `id` दें @msg no text objects selected कोई टेक्स्ट ऑब्जेक्ट नहीं चुना गया @msg no threaded text selected कोई थ्रेडेड टेक्स्ट नहीं चुना गया @msg no toolbar named `{_1}` `{_1}` नाम का कोई टूलबार नहीं @msg no toolbar named `{name}` कोई टूलबार जिसका नाम `{name}` है -@msg no tracing preset `{preset}` (see imageTrace.presets) कोई अनुरेखण पूर्व निर्धारित `{preset}` (imageTrace.presets देखें) +@msg no tracing preset `{preset}` (see imageTrace.presets) `{preset}` नाम का कोई ट्रेसिंग प्रीसेट नहीं (imageTrace.presets देखें) @msg no user library folder here यहां कोई उपयोगकर्ता लाइब्रेरी फ़ोल्डर नहीं @msg no user library folder here (save with a path, or without one to get the data) यहाँ कोई यूज़र लाइब्रेरी फ़ोल्डर नहीं है (पाथ के साथ सेव करें, या डेटा पाने के लिए बिना पाथ के) @msg no user workspace `{from}` कोई उपयोगकर्ता वर्कस्पेस `{from}` @@ -7445,19 +7445,19 @@ antiAlias Sharp शार्प @msg not a closed path एक बंद पाथ नहीं @msg not a DXF drawing नहीं एक DXF ड्राइंग @msg not a file फ़ाइल नहीं -@msg not a flattener presets file (format `{_1}`) नहीं एक flattener presets फ़ाइल (format `{_1}`) -@msg not a flattener presets file: {e} नहीं एक flattener presets फ़ाइल: {e} +@msg not a flattener presets file (format `{_1}`) यह फ़्लैटनर प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) +@msg not a flattener presets file: {e} यह फ़्लैटनर प्रीसेट फ़ाइल नहीं है: {e} @msg not a font file एक फ़ॉन्ट फ़ाइल नहीं @msg not a gradient mesh or mesh envelope एक ग्रेडिएंट जाल या जाल लिफाफे नहीं -@msg not a graphic style library (format `{_1}`) नहीं एक ग्राफिक स्टाइल लाइब्रेरी (format `{_1}`) +@msg not a graphic style library (format `{_1}`) यह ग्राफ़िक स्टाइल लाइब्रेरी नहीं है (फ़ॉर्मैट `{_1}`) @msg not a layer or group एक लेयर या समूह नहीं @msg not a library file: {e} एक लाइब्रेरी फ़ाइल नहीं: {e} @msg not a mesh envelope point एक जाल लिफाफा बिंदु नहीं -@msg not a PDF presets file (format `{_1}`) नहीं एक PDF प्रीसेट फ़ाइल (format `{_1}`) -@msg not a PDF presets file: {e} PDF Presets फ़ाइल नहीं: {e} -@msg not a perspective grid presets file (format `{_1}`) नहीं एक पर्सपेक्टिव ग्रिड presets फ़ाइल (format `{_1}`) +@msg not a PDF presets file (format `{_1}`) यह PDF प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) +@msg not a PDF presets file: {e} यह PDF प्रीसेट फ़ाइल नहीं है: {e} +@msg not a perspective grid presets file (format `{_1}`) यह पर्सपेक्टिव ग्रिड प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) @msg not a perspective grid presets file: {e} एक पर्सपेक्टिव ग्रिड प्रीसेट फ़ाइल नहीं: {e} -@msg not a print presets file (format `{_1}`) कोई प्रिंट प्रीसेट फ़ाइल नहीं (format `{_1}`) +@msg not a print presets file (format `{_1}`) यह प्रिंट प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) @msg not a print presets file: {e} एक प्रिंट प्रीसेट फ़ाइल नहीं: {e} @msg not a PSD file PSD फ़ाइल नहीं @msg not a shortcut set: the text doesn't start with a slash name and an opening brace एक शॉर्टकट सेट नहीं: टेक्स्ट एक स्लैश नाम और एक उद्घाटन ब्रेस के साथ शुरू नहीं होता है @@ -7479,12 +7479,12 @@ antiAlias Sharp शार्प @msg not an HP-GL file HP-GL फ़ाइल नहीं है @msg not a PICT file PICT फ़ाइल नहीं है @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए -@msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) +@msg not an export presets file (format `{_1}`) यह एक्सपोर्ट प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) @msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है @msg not area type (text in a frame) outside perspective पर्सपेक्टिव के बाहर का एरिया टाइप (फ़्रेम में टेक्स्ट) नहीं है -@msg not editing an opacity mask एक opacity मास्क संपादित न करें +@msg not editing an opacity mask ओपेसिटी मास्क संपादित नहीं किया जा रहा है @msg not found नहीं मिला @msg not in pattern editing mode पैटर्न संपादन मोड में नहीं @msg not JSON: {e} JSON नहीं है: {e} @@ -7533,11 +7533,11 @@ antiAlias Sharp शार्प @msg object {id} is not a selected top-level object ऑब्जेक्ट {id} एक सिलेक्टेड शीर्ष स्तर की ऑब्जेक्ट नहीं है @msg object has no weighted stroke ऑब्जेक्ट में कोई भारित स्ट्रोक नहीं है @msg object ids are non-negative integers, not {v} ऑब्जेक्ट ids गैर-नकारात्मक पूर्णांक हैं, न कि {v} -@msg object is not a path object: ऑब्जेक्ट एक पाथ नहीं है +@msg object is not a path object एक पाथ नहीं है @msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object ऑब्जेक्ट पारदर्शिता को हाइलाइट किए गए पात्रों पर नमूने नहीं दिए जा सकते हैं; आइड्रॉपर ऑप्शन्स में पारदर्शिता को अक्षम करें या पूरे ऑब्जेक्ट सिलेक्ट करें @msg objects can't be nested more than 32 deep ऑब्जेक्ट्स को 32 से अधिक गहराई तक घोंसला नहीं किया जा सकता है @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path एक मिश्रण, लिफ़ाफ़ाफ़ा, दोहराने या अंतर्जातीय समूह के अंदर ऑब्जेक्ट्स को एक पाथ से जोड़ा नहीं जा सकता है -@msg objects must be none, move or copy objects: ऑब्जेक्टएं कोई नहीं, चाल या प्रतिलिपि होना चाहिए +@msg objects must be none, move or copy objects none, move या copy होना चाहिए @msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1} @msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था @@ -7558,17 +7558,17 @@ antiAlias Sharp शार्प @msg only solid-colour swatches can be spot colours केवल ठोस रंग वाले स्वॉच स्पॉट रंग हो सकते हैं @msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है; फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें @msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first केवल इस Affinity फ़ाइल के एम्बेडेड प्रीव्यू को पढ़ा जा सकता है: फ़ाइल का उपयोग करें > इसे अपनी चेतावनी के साथ देखने के लिए ओपन करें, या पहले Affinity से SVG या PDF निर्यात करें -@msg onto must name a floating panel onto: पर एक फ्लोटिंग पैनल का नाम होना चाहिए -@msg onto must name a panel floating in another group onto: किसी अन्य समूह में फ्लोटिंग पैनल का नाम होना चाहिए +@msg onto must name a floating panel onto में किसी फ़्लोटिंग पैनल का नाम होना चाहिए +@msg onto must name a panel floating in another group onto में किसी दूसरे ग्रुप में फ़्लोट कर रहे पैनल का नाम होना चाहिए @msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon और column स्थान window.panel.float और window.panel.dock के लिए हैं @msg onto, above, below, icon and a column place take no anchor, side, zone or before ऊपर, नीचे, आइकन और एक स्तंभ स्थान पर कोई एंकर, साइड, जोन या पहले नहीं होता है @msg onto, above, below, icon and a column place take no x, y, width or height ऊपर, नीचे, आइकन और एक स्तंभ स्थान पर कोई एक्स, वाई, चौड़ाई या ऊंचाई नहीं होती है @msg open a document to look for its linked files अपनी लिंक्ड फ़ाइलों को देखने के लिए एक डॉक्यूमेंट ओपन करें @msg open a document with data sets डेटा सेट के साथ एक डॉक्यूमेंट ओपन करें @msg Opened {url} {url} ओपन किया गया -@msg options must be an object (see file.formatOptions) options: ऑप्शन्स एक ऑब्जेक्ट होना चाहिए (file.formatOptions देखें) +@msg options must be an object (see file.formatOptions) options एक ऑब्जेक्ट होना चाहिए (file.formatOptions देखें) @msg order must be leftToRight or rightToLeft order को leftToRight या rightToLeft होना चाहिए -@msg orientation must be portrait or landscape, not `{o}` orientation: अभिविन्यास चित्र या परिदृश्य होना चाहिए, नहीं `{o}` +@msg orientation must be portrait or landscape, not `{o}` orientation portrait या landscape होना चाहिए, `{o}` नहीं @msg others must be true or false others को true या false होना चाहिए @msg Outline Stroke: no stroked paths selected आउटलाइन स्ट्रोक: सिलेक्टेड कोई स्ट्रोक पाथ नहीं @msg outlineText {v}: true or false outlineText {v}: true या false @@ -7596,49 +7596,49 @@ antiAlias Sharp शार्प @msg page art is missing image data for {key} पृष्ठ कला {key} के लिए इमेज डेटा लापता है @msg page art is missing pattern {name} पृष्ठ कला लापता पैटर्न {name} है @msg page art is missing swatch {name} पेज आर्टवर्क में स्वॉच {name} नहीं है -@msg page must be a whole number from 1 page: पृष्ठ 1 से एक पूरी संख्या होना चाहिए +@msg page must be a whole number from 1 page 1 या उससे बड़ी पूर्ण संख्या होनी चाहिए @msg page size {w} × {h} {w} × {h} @msg page thumbnail: {e} पेज थंबनेल: {e} -@msg pages must be a range such as "2-3" or a page number, not {v} pages: पृष्ठों को "2-3" या एक पृष्ठ संख्या के रूप में एक श्रेणी होना चाहिए, नहीं {v} +@msg pages must be a range such as "2-3" or a page number, not {v} pages "2-3" जैसी रेंज या पेज संख्या होनी चाहिए, {v} नहीं @msg pages: {e} पेज: {e} @msg panel group is empty पैनल समूह खाली है -@msg panel is not in a dock group panel: पैनल एक डॉक समूह में नहीं है -@msg panel must name a registered panel panel: पैनल का नाम एक पंजीकृत पैनल होना चाहिए +@msg panel is not in a dock group panel किसी डॉक समूह में नहीं है +@msg panel must name a registered panel panel किसी पंजीकृत पैनल का नाम होना चाहिए @msg panel position must be finite पैनल की स्थिति परिमित होना चाहिए @msg parameter `{k}`: {what} पैरामीटर `{k}`: {what} @msg parameter `{name}`: {what} पैरामीटर `{name}`: {what} -@msg parameters must be an object parameters: पैरामीटर एक ऑब्जेक्ट होना चाहिए -@msg params must be an object params: परम एक ऑब्जेक्ट होना चाहिए +@msg parameters must be an object parameters एक ऑब्जेक्ट होना चाहिए +@msg params must be an object params एक ऑब्जेक्ट होना चाहिए @msg part of the program isn't valid PostScript text कार्यक्रम का हिस्सा मान्य नहीं है PostScript टेक्स्ट @msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice और/या type दें @msg passwords can have up to {MAX_PASSWORD} bytes पासवर्ड {MAX_PASSWORD} बाइट्स तक हो सकता है @msg paste unavailable पेस्ट करें अनुपलब्ध @msg Pasting चिपकाना @msg path has no open end पाथ का कोई खुला अंत नहीं है -@msg path is already closed path: पाथ पहले से ही बंद हो गया है -@msg path must be a list of child indices path: बच्चे के सूचकांक की सूची होना चाहिए +@msg path is already closed path पहले से बंद है +@msg path must be a list of child indices path चाइल्ड इंडेक्स की सूची होनी चाहिए @msg paths contain invalid coordinates पाथ में अमान्य निर्देशांक होते हैं -@msg paths must be strings paths: पाथ स्ट्रिंग होना चाहिए +@msg paths must be strings paths स्ट्रिंग होने चाहिए @msg paths: give at least one file पाथ: कम से कम एक फ़ाइल दें @msg pattern missing पैटर्न लापता @msg patterns nested too deeply are filled with mid-grey पैटर्न घोंसला बहुत गहराई से मध्य ग्रे से भर रहे हैं @msg PCX files with {bits}-bit pixels in {planes} planes can't be read पीसीएक्स फ़ाइलों के साथ {bits}-बिट पिक्सल {planes} विमानों में पढ़ा नहीं जा सकता है @msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF सामग्री अधूरे है या टेक्स्टक की सीमाओं से अधिक है; कोई डॉक्यूमेंट आयात नहीं किया गया था -@msg PDF flattener: {e} PDF flattener: {e} +@msg PDF flattener: {e} PDF फ़्लैटनर: {e} @msg PDF options: {e} PDF ऑप्शन्स: {e} @msg PDF security settings are not an object PDF सुरक्षा सेटिंग्स एक ऑब्जेक्ट नहीं हैं @msg PDF settings are not an object PDF सेटिंग्स एक ऑब्जेक्ट नहीं हैं @msg PDF writer error: {0} PDF लेखक त्रुटि: {0} @msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false में एडिट करने योग्य आर्टवर्क रखने के लिए सभी आर्टबोर्ड चाहिए @msg pdfs `{m}`: single or multiple pdfs `{m}`: एकल या एकाधिक -@msg percent must be above 0 and at most 6400, not {p} percent: प्रतिशत 0 से अधिक होना चाहिए और अधिक 6400 पर, नहीं {p} +@msg percent must be above 0 and at most 6400, not {p} percent 0 से बड़ा और अधिकतम 6400 होना चाहिए, {p} नहीं @msg Persistent libraries not found: {files} Persistent लाइब्रेरीों नहीं मिला: {files} @msg pick a colour, gradient or pattern to save as a swatch स्वॉच के रूप में सेव करने के लिए रंग, ग्रेडिएंट या पैटर्न चुनें @msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom को "artwork" या "swatches" होना चाहिए, {v} नहीं @msg pins and moved must be non-empty lists of the same length पिन और ले जाने के लिए समान लंबाई की गैर-खाली सूची होनी चाहिए -@msg pins must be [[x,y]…] pins: पिन होना चाहिए [[x,y]...] -@msg pins must be finite pins: पिन परिमित होना चाहिए -@msg place is above, below or inside, not `{other}` place: स्थान ऊपर, नीचे या अंदर है, नहीं `{other}` +@msg pins must be [[x,y]…] pins [[x,y]…] होना चाहिए +@msg pins must be finite pins परिमित होने चाहिए +@msg place is above, below or inside, not `{other}` place above, below या inside होता है, `{other}` नहीं @msg Placing {_1} पतंग {_1} @msg plane must be left, right or ground plane को left, right या ground होना चाहिए @msg plane must be left|right|ground plane को left|right|ground होना चाहिए @@ -7653,24 +7653,24 @@ antiAlias Sharp शार्प @msg plug-in trapped: {0} प्लग-इन में ट्रैप हुआ: {0} @msg Plug-ins can't be loaded from {path}: {e} प्लग-इन {path} से लोड नहीं किया जा सकता: {e} @msg point is outside the mesh बिंदु मेश के बाहर है -@msg points must be [t, left, right] with non-negative widths points: गैर-नकारात्मक चौड़ाई के साथ अंक [t, left, right] होना चाहिए -@msg points must be a non-empty [[x,y,pressure?]…] list points: अंक एक गैर-खाली होना चाहिए [[x,y, दबाव?]...] सूची -@msg points must be a non-empty list of finite [x,y] points: अंक परिमित की एक गैर-खाली सूची होना चाहिए [x,y] -@msg points must be an array or null points: अंक एक सारणी या null होना चाहिए -@msg points must be finite points: अंक परिमित होना चाहिए -@msg points must be finite [x,y] pairs points: अंक परिमित होना चाहिए [x,y] जोड़े +@msg points must be [t, left, right] with non-negative widths points गैर-ऋणात्मक चौड़ाई वाले [t, left, right] होने चाहिए +@msg points must be a non-empty [[x,y,pressure?]…] list points गैर-खाली [[x,y,pressure?]…] सूची होनी चाहिए +@msg points must be a non-empty list of finite [x,y] points परिमित [x,y] की गैर-खाली सूची होनी चाहिए +@msg points must be an array or null points ऐरे या null होना चाहिए +@msg points must be finite points परिमित होने चाहिए +@msg points must be finite [x,y] pairs points परिमित [x,y] जोड़े होने चाहिए @msg points must contain 1–4096 coordinates points में 1–4096 निर्देशांक होने चाहिए -@msg position must be normal, superscript or subscript position: स्थिति सामान्य, सुपरस्क्रिप्ट या सबस्क्रिप्ट होना चाहिए +@msg position must be normal, superscript or subscript position normal, superscript या subscript होना चाहिए @msg position must be topLeft, topRight, bottomLeft or bottomRight position को topLeft, topRight, bottomLeft या bottomRight होना चाहिए -@msg positions must be between 0 and 100000 pt positions: 0 और 100000 pt के बीच होना चाहिए -@msg ppi must be a positive number, not {ppi} ppi एक सकारात्मक संख्या होना चाहिए, नहीं {ppi} -@msg ppi must be between 1 and 2400 ppi: पीपीआई 1 और 2400 के बीच होना चाहिए -@msg precision must be from 0.001 to 10 pt precision: परिशुद्धता 0.001 से 10 pt तक होना चाहिए +@msg positions must be between 0 and 100000 pt positions 0 और 100000 pt के बीच होनी चाहिए +@msg ppi must be a positive number, not {ppi} ppi एक धनात्मक संख्या होनी चाहिए, {ppi} नहीं +@msg ppi must be between 1 and 2400 ppi 1 और 2400 के बीच होना चाहिए +@msg precision must be from 0.001 to 10 pt precision 0.001 से 10 pt तक होनी चाहिए @msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}`, transforms|bounds|fileDimensions|fit|fill में से नहीं है @msg preserveShape must be clippingMask|transparency preserveShape को clippingMask|transparency होना चाहिए @msg preset `{id}`: mobile or density प्रीसेट `{id}`: मोबाइल या घनत्व @msg preset must be standard, diffuse, topLeft or right preset को standard, diffuse, topLeft या right होना चाहिए -@msg preset must be the name of a print preset preset: प्रीसेट एक प्रिंट प्रीसेट का नाम होना चाहिए +@msg preset must be the name of a print preset preset किसी प्रिंट प्रीसेट का नाम होना चाहिए @msg Preview in Browser needs the desktop app (the web downloads files instead) ब्राउज़र में प्रीव्यू डेस्कटॉप ऐप (वेब डाउनलोड फ़ाइलों के बजाय) की जरूरत है @msg previewMode must be default, pixel or overprint previewMode को default, pixel या overprint होना चाहिए @msg Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first प्रिंट इंक फिक्स CMYK डॉक्यूमेंट्स पर काम करता है: फ़ाइल चुनें › डॉक्यूमेंट रंग मोड › CMYK कलर फर्स्ट @@ -7686,16 +7686,16 @@ antiAlias Sharp शार्प @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 rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi को 1–2400 होना चाहिए (72 स्क्रीन, 150 मध्यम, 300 उच्च) -@msg rating must be a whole number from 0 to {MAX_RATING} rating: रेटिंग 0 से {MAX_RATING} तक पूरी संख्या होना चाहिए +@msg rating must be a whole number from 0 to {MAX_RATING} rating 0 से {MAX_RATING} तक की पूर्ण संख्या होनी चाहिए @msg Recolor Artwork is no longer open Recolor Artwork अब खुला नहीं है @msg recovery copy `{file}`: {e} वसूली प्रतिलिपि `{file}`: {e} @msg rect and {k} contradict each other: give the area or the artboards सही और {k} एक दूसरे के विपरीत: क्षेत्र या आर्टबोर्ड देना @msg rect applies to raster formats, not {_1} सही रेस्टर फ़ॉर्मैट्स पर लागू होता है, न कि {_1} @msg rect is [x, y, width, height] in points rect पॉइंट में [x, y, width, height] है -@msg rect must be [x, y, width, height] rect: [x, y, चौड़ाई, ऊंचाई] होना चाहिए -@msg rect must be [x, y, width, height] with a positive width and height rect: सही होना चाहिए [x, y, चौड़ाई, ऊंचाई] सकारात्मक चौड़ाई और ऊंचाई के साथ -@msg rect needs finite numbers and a positive width and height rect: सही की जरूरत परिमित संख्या और सकारात्मक चौड़ाई और ऊंचाई -@msg redraw needs id redraw: ई.डी. की आवश्यकता +@msg rect must be [x, y, width, height] rect [x, y, width, height] होना चाहिए +@msg rect must be [x, y, width, height] with a positive width and height rect धनात्मक चौड़ाई और ऊँचाई वाला [x, y, width, height] होना चाहिए +@msg rect needs finite numbers and a positive width and height rect में परिमित संख्याएँ और धनात्मक चौड़ाई और ऊँचाई चाहिए +@msg redraw needs id redraw के लिए id चाहिए @msg redraw within must be a finite distance of 0 or more भीतर वापस लेना 0 या उससे अधिक की एक सीमित दूरी होना चाहिए @msg registration must be 0..8 registration को 0..8 होना चाहिए @msg Rejoin Path Pieces: too many paths to rejoin at once पाथ के हिस्से फिर से जोड़ें: एक बार में फिर से जोड़ने के लिए बहुत अधिक पाथ हैं @@ -7704,17 +7704,17 @@ antiAlias Sharp शार्प @msg Release: no layer to put the rectangle in रिलीज: आयत को डालने के लिए कोई लेयर नहीं @msg Reload the page to go on (changes not saved yet may be lost). जारी रखने के लिए पेज फिर से लोड करें (जो बदलाव अभी सेव नहीं हुए, वे खो सकते हैं)। @msg replace keeps the replaced object's place and transform: drop template, at and rect प्रतिस्थापित ऑब्जेक्ट के स्थान को बनाए रखता है और बदल देता है: ड्रॉप टेम्पलेट, पर और आयतन -@msg Replace Spine: select a blend and a path Spine: एक मिश्रण और एक पाथ चुनें +@msg Replace Spine: select a blend and a path स्पाइन रिप्लेस करें: एक ब्लेंड और एक पाथ सिलेक्ट करें @msg replace: select the one object to replace प्रतिस्थापित करने के लिए एक ऑब्जेक्ट सिलेक्ट करें @msg replace: the object to replace was changed or deleted प्रतिस्थापित करने के लिए ऑब्जेक्ट को बदल दिया गया था या हटा दिया गया @msg the document it was prepared for is no longer active जिस डॉक्यूमेंट के लिए इसे तैयार किया गया था, वह अब सक्रिय नहीं है @msg Reset {_1} रीसेट करें {_1} -@msg resolution must be a number or screen|medium|high resolution: रिज़ॉल्यूशन एक नंबर या स्क्रीन होना चाहिए -@msg resolution must be between 1 and 2400 ppi resolution: रिज़ॉल्यूशन 1 और 2400 ppi के बीच होना चाहिए -@msg resolutions must be between 1 and 2400 ppi resolutions: रिज़ॉल्यूशन 1 और 2400 ppi के बीच होना चाहिए +@msg resolution must be a number or screen|medium|high resolution कोई संख्या या screen|medium|high होना चाहिए +@msg resolution must be between 1 and 2400 ppi resolution 1 और 2400 ppi के बीच होना चाहिए +@msg resolutions must be between 1 and 2400 ppi resolutions 1 और 2400 ppi के बीच होने चाहिए @msg result would exceed the canvas (coordinates out of range) परिणाम कैनवास से अधिक होगा (श्रेणी से बाहर समन्वय) -@msg rotation must be a number of degrees rotation: रोटेशन डिग्री की एक संख्या होना चाहिए -@msg rotation must be finite rotation: रोटेशन को परिमित होना चाहिए +@msg rotation must be a number of degrees rotation डिग्री में एक संख्या होनी चाहिए +@msg rotation must be finite rotation परिमित होना चाहिए @msg rows and cols must be 1..50 पंक्तियां और कॉल 1..50 होना चाहिए @msg rows and columns must be 1..500 rows और columns को 1..500 होना चाहिए @msg rows of objects whose appearances differ can't be reordered together उन ऑब्जेक्ट्स की पंक्तियाँ जिनकी उपस्थिति भिन्न होती हैं, उन्हें एक साथ फिर से व्यवस्थित नहीं किया जा सकता है @@ -7723,7 +7723,7 @@ antiAlias Sharp शार्प @msg save the document first: copy puts the file in its folder पहले डॉक्यूमेंट को सेव करना: प्रतिलिपि फ़ाइल को अपने फ़ोल्डर में डालती है @msg save the document first: Package collects a saved document पहले डॉक्यूमेंट को सेव करना: पैकेज एक सेव किए गए डॉक्यूमेंट एकत्र करता है @msg Save writes {_1}, not {_2}: use document.export for other formats सेव {_1} लिखता है, {_2} नहीं: दूसरे फ़ॉर्मैट के लिए document.export उपयोग करें -@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it सेव करें {_1}, not {_2}: use file.saveAsTemplate to write it +@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it सेव {_1} लिखता है, {_2} नहीं: इसे लिखने के लिए file.saveAsTemplate का उपयोग करें @msg Save writes one artboard: name one, or export several with document.export सेव एक आर्टबोर्ड लिखता है: एक का नाम दें या कई आर्टबोर्ड document.export से एक्सपोर्ट करें @msg Saved {n} files beside {path} {path} के पास {n} फ़ाइलें सेव की गईं @msg Saved {path} सेव्ड {path} @@ -7736,18 +7736,18 @@ antiAlias Sharp शार्प @msg Saving {_1} बचत {_1} @msg Saving recovery data for {_1} {_1} के लिए रिकवरी डेटा सेव करना @msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) पैमाने `{s}`: एक कारक जैसे 2x, चौड़ाई (100w), ऊंचाई (100h) या एक रिज़ॉल्यूशन (72ppi) -@msg scale must be a number above 0, at most 64 scale: पैमाने 0 से अधिक होना चाहिए, अधिकांश 64 में -@msg scale must be a number from {_1} to {_2}, not {_3} scale: स्केल {_1} से {_2} के लिए एक नंबर होना चाहिए, नहीं {_3} -@msg scale must be a number from {_1} to {_2}, not {s} scale: स्केल {_1} से {_2} के लिए एक नंबर होना चाहिए, नहीं {s} +@msg scale must be a number above 0, at most 64 scale 0 से बड़ी और अधिकतम 64 संख्या होनी चाहिए +@msg scale must be a number from {_1} to {_2}, not {_3} scale {_1} से {_2} तक की संख्या होनी चाहिए, {_3} नहीं +@msg scale must be a number from {_1} to {_2}, not {s} scale {_1} से {_2} तक की संख्या होनी चाहिए, {s} नहीं @msg Scale must be drawing:real, for example 1:10 स्केल ड्राइंग होना चाहिए:वास्तविक, उदाहरण के लिए 1:10 -@msg scale must be non-zero scale: स्केल होना चाहिए गैर-zero +@msg scale must be non-zero scale शून्य नहीं होना चाहिए @msg Scale ratio must be between 1:1000000 and 1000000:1 स्केल अनुपात 1:1000000 और 1000000:1 के बीच होना चाहिए @msg Scale Text Attributes: selected type has no scale to bake स्केल टेक्स्ट एट्रिब्यूट्स: सिलेक्टेड टाइप में बेक करने के लिए कोई स्केल नहीं है -@msg scope must be selection or all (got {other}) scope: गुंजाइश का सिलेक्शन होना चाहिए या सभी ({other}) होना चाहिए। +@msg scope must be selection or all (got {other}) scope selection या all होना चाहिए (मिला {other}) @msg scribble is too long स्क्रिबल बहुत लंबा है @msg searching a folder isn't available on the web एक फ़ोल्डर खोज वेब पर उपलब्ध नहीं है @msg Second measured segment no longer exists दूसरा मापा खंड अब मौजूद नहीं है -@msg seconds must be a whole number from 1 to 60 seconds: सेकंड 1 से 60 तक पूरी संख्या होना चाहिए +@msg seconds must be a whole number from 1 to 60 seconds 1 से 60 तक की पूर्ण संख्या होनी चाहिए @msg segment does not exist खंड मौजूद नहीं है @msg select (or target) objects whose appearance isn't basic सिलेक्शन (या लक्ष्य) ऑब्जेक्ट जिनकी उपस्थिति बुनियादी नहीं है @msg select a blend मिश्रण सिलेक्ट करें @@ -7868,9 +7868,9 @@ antiAlias Sharp शार्प @msg select type on a path एक पाथ पर टाइप करें @msg selection `{m}`: one or each सिलेक्शन `{m}`: एक या प्रत्येक @msg selection has no bounds सिलेक्शन की कोई सीमा नहीं है -@msg Sent to {_1} Sent to {_1} +@msg Sent to {_1} {_1} को भेजा गया @msg settings is an object of document.exportForScreens params settings, document.exportForScreens पैरामीटर का ऑब्जेक्ट है -@msg settings must be an object settings: सेटिंग्स एक ऑब्जेक्ट होना चाहिए +@msg settings must be an object settings एक ऑब्जेक्ट होना चाहिए @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 छायांकन जिसका कार्य अज्ञात प्रकार के होते हैं, उनके मध्य रंग से भरे होते हैं @@ -7879,33 +7879,33 @@ antiAlias Sharp शार्प @msg Shaper needs 1–64 source paths शेपर के लिए 1–64 स्रोत पाथ चाहिए @msg shortcut for `{k}` must be a string `{k}` के लिए शॉर्टकट एक स्ट्रिंग होना चाहिए @msg show `{show}`: all, missing, modified or embedded `{show}``: सभी, लापता, संशोधित या एम्बेडेड -@msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है +@msg Show All Hidden Attributes: nothing is hidden सभी छिपे हुए गुण दिखाएँ: कुछ भी छिपा नहीं है @msg show must be true or false show को true या false होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं -@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या पॉइंट में 0 से अधिक और {_1} तक की संख्या होना चाहिए -@msg size must be positive size: आकार सकारात्मक होना चाहिए +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size "small", "medium", "large" या पॉइंट में 0 से अधिक और {_1} तक की संख्या होना चाहिए +@msg size must be positive size धनात्मक होना चाहिए @msg sizeMode is "fixed" or "pressure", not {other} sizeMode को "fixed" या "pressure" होना चाहिए, {other} नहीं @msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode को "fixed" या "pressure" होना चाहिए, `{other}` नहीं @msg sizeVariation must be 0 or more sizeVariation को 0 या अधिक होना चाहिए @msg smallCaps must be true or false smallCaps को true या false होना चाहिए -@msg some text is hidden or locked, so its font wasn't replaced कुछ टेक्स्ट छिपा हुआ है या बंद कर दिया गया है, इसलिए इसका फ़ॉन्ट प्रतिस्थापित नहीं किया गया था +@msg some text is hidden or locked, so its font wasn't replaced कुछ टेक्स्ट छिपा या लॉक है, इसलिए उसका फ़ॉन्ट बदला नहीं गया @msg sort `{x}`: name, kind or status sort `{x}`: name, kind या status होना चाहिए -@msg source must be the id of a Shaper Group's original shape source: स्रोत एक शेपर ग्रुप के मूल आकार का आईडी होना चाहिए +@msg source must be the id of a Shaper Group's original shape source किसी शेपर ग्रुप के मूल आकार का id होना चाहिए @msg source paths are too large for Shaper स्रोत पाथ शेपर के लिए बहुत बड़े हैं -@msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला +@msg span must be "object" or "selection", got {v} span "object" या "selection" होना चाहिए, {v} मिला @msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं @msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें @msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" एक रैखिक, रेडियल, शंक्वाकार या डायमंड ग्रेडिएंट रखता है, फ्रीफॉर्म ग्रेडिएंट नहीं @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं) -@msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए +@msg speed must be accelerated, step or pause speed accelerated, step या pause होना चाहिए @msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें @msg spot, mode and group apply to solid colours only स्पॉट, मोड और समूह केवल ठोस रंगों पर लागू होते हैं @msg status `{s}`: linked, embedded, missing or modified स्थिति `{s}`: लिंक्ड, एम्बेडेड, लापता या संशोधित -@msg steps must be 1..{MAX_STEPS} steps: चरण 1 होना चाहिए।{MAX_STEPS} +@msg steps must be 1..{MAX_STEPS} steps 1..{MAX_STEPS} होना चाहिए @msg steps must be 1..1000 steps को 1..1000 होना चाहिए @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 stop {i}: `{k}` must be a number स्टॉप {i}: `{k}` एक संख्या होनी चाहिए @msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) स्टॉप {i}: `plates` में 0 से 1 के बीच चार संख्याएँ होनी चाहिए (C, M, Y, K) @msg stop editing the opacity mask first पहली बार अपारदर्शिता मास्क संपादित करना बंद करो @msg stopped before the archive was finished संग्रह समाप्त होने से पहले बंद कर दिया गया @@ -7922,7 +7922,7 @@ antiAlias Sharp शार्प @msg Targa files are RGB: colorModel rgb Targa फ़ाइलें RGB हैं: colorModel rgb @msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text को {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} होना चाहिए @msg Text options: {e} टेक्स्ट ऑप्शन्स: {e} -@msg text transform is collapsed or unrepresentable टेक्स्ट रूपांतरण पतन या प्रतिनिधित्व करने योग्य है +@msg text transform is collapsed or unrepresentable टेक्स्ट ट्रांसफ़ॉर्म सिमटा हुआ है या उसे दर्शाया नहीं जा सकता @msg Text Wrap: select a wrap object टेक्स्ट लपेटें: एक रैप ऑब्जेक्ट चुनें @msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें। @msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए @@ -8015,7 +8015,7 @@ antiAlias Sharp शार्प @msg the art on them differs from the page's in {_1}% of it उनमें से कला पृष्ठ के {_1}% में भिन्न होती है। @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same उनमें से कला पृष्ठ के {_1}% में भिन्न होती है: वे इसके संपादन डेटा से आते हैं। @msg the artboard is locked आर्टबोर्ड को लॉक किया जाता है -@msg the artboard to fit must be a positive size up to {_1} pt फिट करने के लिए आर्टबोर्ड {_1} pt तक सकारात्मक आकार होना चाहिए +@msg the artboard to fit must be a positive size up to {_1} pt फिट करने के लिए आर्टबोर्ड का आकार {_1} pt तक धनात्मक होना चाहिए @msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size आर्टबोर्ड कैनवास पर फिट नहीं हैं: कम आर्टबोर्ड, कम रिक्ति या छोटे आकार @msg the background worker stopped पृष्ठभूमि कार्यकर्ता बंद कर दिया @msg the blend has no spine मिश्रण में कोई रीढ़ नहीं है @@ -8067,7 +8067,7 @@ antiAlias Sharp शार्प @msg the file was updated or encrypted already फ़ाइल को पहले से ही अद्यतन या एन्क्रिप्ट किया गया था @msg the file's layers weren't read ({_1}): it comes in as its page, in one layer फ़ाइल की लेयरें नहीं पढ़ी गईं ({_1}): यह अपने पृष्ठ के रूप में आता है, एक लेयर में @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part फ़ाइल की लेयर्स को इसके संपादन डेटा ({_1}) से नहीं पढ़ा गया था: वे अपने PDF भाग से आते हैं -@msg the file's preview header points outside the file फ़ाइल के प्रीव्यू शीर्षलेख फ़ाइल के बाहर अंक +@msg the file's preview header points outside the file फ़ाइल का प्रीव्यू हेडर फ़ाइल के बाहर की जगह बताता है @msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables फ़ाइल की कुछ वेरिएबल बाइंडिंग नहीं पढ़ी गईं (छोड़ी गई बाइंडिंग: {_1}): वे ऑब्जेक्ट अपने वेरिएबल्स से नहीं बँधे हैं @msg the file's variable library couldn't be read: the Variables panel doesn't list it फ़ाइल की वेरिएबल लाइब्रेरी पढ़ी नहीं जा सकी: वेरिएबल्स पैनल उसे सूचीबद्ध नहीं करता @msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest फ़ाइल की वेरिएबल लाइब्रेरी आंशिक रूप से पढ़ी गई (छोड़ी गई प्रविष्टियाँ: {_1}): वेरिएबल्स पैनल शेष प्रविष्टियाँ दिखाता है @@ -8109,7 +8109,7 @@ antiAlias Sharp शार्प @msg the job would print more than {_1} pages नौकरी {_1} पृष्ठों से अधिक प्रिंट करेगी @msg the job would print more than {MAX_PAGES} pages इस जॉब में {MAX_PAGES} से अधिक पेज प्रिंट होंगे @msg the JPEG 2000 file can't be read JPEG 2000 फ़ाइल पढ़ा नहीं जा सकता -@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 फ़ाइल में एक unसमर्थित रंग स्थान या JPX सामग्री है +@msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 फ़ाइल में असमर्थित कलर स्पेस या JPX सामग्री है @msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set वेक्टर क्रेफ्ट की अंतिम शुरुआत अपनी विंडो दिखाई देने से पहले {name} पर समाप्त हुई, इसलिए यह {summary} पर शुरू हुआ। उस ग्राफिक्स प्रोसेसर को फिर से आज़माने के लिए, वरीयताओं को बदलें › प्रदर्शन › ग्राफिक्स प्रोसेसर या WGPU_BACKEND सेट के साथ शुरू होता है। @msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set जब यह ग्राफिक्स प्रोसेसर को सूचीबद्ध करता है, तो यह केवल ओपनजीएल के साथ {summary} पर शुरू हुआ। फिर से अन्य ग्राफिक्स बैकेंड की कोशिश करने के लिए, वरीयताओं को बदलें › प्रदर्शन › ग्राफिक्स प्रोसेसर या WGPU_BACKEND सेट के साथ शुरू होता है। @msg the layer has no objects to clip by लेयर में क्लिप करने के लिए कोई ऑब्जेक्ट नहीं है @@ -8184,7 +8184,7 @@ antiAlias Sharp शार्प @msg the program asks for a resource that isn't defined कार्यक्रम एक संसाधन के लिए पूछता है जिसे परिभाषित नहीं किया गया है @msg the program ends a dictionary it didn't begin कार्यक्रम एक शब्दकोश को समाप्त करता है यह शुरू नहीं हुआ था @msg the program goes past one of the reader's limits कार्यक्रम रीडर की सीमाओं में से एक के पास जाता है -@msg the program opens a file, which the reader can't do कार्यक्रम एक फ़ाइल खोलता है, जिसे टेक्स्टक नहीं कर सकता +@msg the program opens a file, which the reader can't do प्रोग्राम एक फ़ाइल ओपन करता है, जो रीडर नहीं कर सकता @msg the program restores a saved state that was already restored प्रोग्राम एक सेव की गई स्थिति को फिर से बहाल करता है, जो पहले ही बहाल हो चुकी थी @msg the program runs too long कार्यक्रम बहुत लंबे समय तक चलता है @msg the program runs, deletes or renames a file, which the reader can't do कार्यक्रम एक फ़ाइल को चलाता है, हटाता है या उसका नाम बदलता है, जिसे टेक्स्टक नहीं कर सकता @@ -8259,12 +8259,12 @@ antiAlias Sharp शार्प @msg the variable library's document type declaration holds something other than entities of plain text वेरिएबल लाइब्रेरी के डॉक्यूमेंट प्रकार की घोषणा सादे टेक्स्ट की संस्थाओं के अलावा कुछ अन्य रखती है @msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes वेरिएबल लाइब्रेरी की संस्थाएं {MAX_LIBRARY_BYTES} बाइट्स से अधिक विस्तार करती हैं @msg the window has no dock विंडो में कोई डॉक नहीं है -@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot विंडो का {what} ({points} अंक × {scale} डीपीआई) एक स्क्रीनशॉट का आकार नहीं दे सकता +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot विंडो का {what} ({points} पॉइंट × {scale} DPI) एक स्क्रीनशॉट का आकार नहीं दे सकता @msg the WMF header is damaged WMF हेडर क्षतिग्रस्त है @msg the Word document has more than {_1} MB of formatting properties, too many to place वर्ड डॉक्यूमेंट में स्वरूपण प्रॉपर्टीज़ के {_1} MB से अधिक है, कई जगहों पर @msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word डॉक्यूमेंट में गुणों के {MAX_PAGES} से अधिक पेज हैं, रखने के लिए बहुत अधिक @msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place वर्ड डॉक्यूमेंट में {MAX_RUNS} से अधिक अक्षर या पैराग्राफ प्रॉपर्टीज़ होते हैं, बहुत से जगह होते हैं। -@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) वर्ड डॉक्यूमेंट में टाइप के रूप में बहुत अधिक टेक्स्ट या स्वरूपण होता है (अधिकांश {MAX_PARAGRAPHS} पैराग्राफ, {MAX_RUNS} रन और {_1} MB of text) +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) वर्ड डॉक्यूमेंट में टाइप के रूप में रखने के लिए बहुत अधिक टेक्स्ट या फ़ॉर्मैटिंग है (अधिकतम {MAX_PARAGRAPHS} पैराग्राफ़, {MAX_RUNS} रन और {_1} MB टेक्स्ट) @msg the Word document has too much text to place as type (at most {_1} MB) वर्ड डॉक्यूमेंट में टाइप के रूप में बहुत अधिक टेक्स्ट होता है (अधिकांश {_1} MB) @msg the Word document is encrypted or protected with a password; save it without one to place it वर्ड डॉक्यूमेंट को एन्क्रिप्ट किया गया है या पासवर्ड के साथ संरक्षित किया गया है; इसे बिना किसी स्थान के सेव करें @msg the Word document is too large to place (its {name} stream holds more than {_1} MB) वर्ड डॉक्यूमेंट बहुत बड़ा होता है (यह {name} स्ट्रीम {_1} MB से अधिक रखता है) @@ -8277,22 +8277,22 @@ antiAlias Sharp शार्प @msg there are no colour swatches to draw ड्रॉ करने के लिए कोई रंग स्वॉच नहीं है @msg there are no objects to release जारी करने के लिए कोई ऑब्जेक्ट नहीं है @msg there are no printer settings to open here यहां ओपन करने के लिए कोई प्रिंटर सेटिंग नहीं है -@msg there is already a design named `{name}` there: पहले से ही `{name}` नामक एक डिज़ाइन है। -@msg there is already a design named `{to}` there: पहले से ही `{to}` नामक एक डिजाइन है -@msg there is already a toolbar named `{name}` there: पहले से ही `{name}` नामक एक टूलबार है। -@msg there is already a toolbar named `{to}` there: पहले से ही `{to}` नामक एक टूलबार है। +@msg there is already a design named `{name}` `{name}` नाम का डिज़ाइन पहले से मौजूद है +@msg there is already a design named `{to}` `{to}` नाम का डिज़ाइन पहले से मौजूद है +@msg there is already a toolbar named `{name}` `{name}` नाम का टूलबार पहले से मौजूद है +@msg there is already a toolbar named `{to}` `{to}` नाम का टूलबार पहले से मौजूद है @msg there is no clock here यहाँ कोई घड़ी नहीं है @msg there is no clock here: give `time` (Unix seconds) यहाँ घड़ी नहीं है: `time` दें (Unix सेकंड) @msg there is no library: create one first वहाँ कोई लाइब्रेरी नहीं है: पहले एक बनाना @msg these paths are too degenerate for Shaper इन पाथ को शेपर के लिए बहुत अलग किया जाता है -@msg thickness must be from 0.01 to 5000 pt thickness: मोटाई 0.01 से 5000 तक होना चाहिए +@msg thickness must be from 0.01 to 5000 pt thickness 0.01 से 5000 pt तक होनी चाहिए @msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) यह फ़ाइल PDF संगतता के बिना सेव की गई थी, और इसका एडिटिंग डेटा पढ़ा नहीं जा सकता ({why}) @msg this file's PostScript couldn't be read ({why}): {what} इस फ़ाइल के पोस्टस्क्रिप्ट को पढ़ा नहीं जा सकता ({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 this: यह एक डीडब्ल्यूजी ड्राइंग है, जिसे पढ़ा नहीं जा सकता है: इसे सीएडी ऐप से ASCII DXF के रूप में सेव करें और इसे ओपन करें कि +@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), not a PDF: File › Open and File › Place read it as EPS यह एक PostScript फ़ाइल है (पुराने फ़ॉर्मैट में या PDF संगतता के बिना सेव की गई .ai, या EPS फ़ाइल), PDF नहीं: फ़ाइल › ओपन और फ़ाइल › प्लेस इसे EPS के रूप में पढ़ते हैं @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG यह एक PostScript फ़ाइल है (पुराने फ़ॉर्मैट में या PDF संगतता के बिना सेव की गई .ai, या EPS फ़ाइल), जिसे अभी ओपन नहीं किया जा सकता: इसे PDF, PDF-संगत .ai या SVG के रूप में सेव करें -@msg this is not a PostScript file this: यह पोस्टस्क्रिप्ट फ़ाइल नहीं है +@msg this is not a PostScript file यह पोस्टस्क्रिप्ट फ़ाइल नहीं है @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead यह एक फ्लैट रंग का लोगो नहीं है ({_1}% अपने ठोस क्षेत्रों में कुछ फ्लैट रंगों से मेल खाता है), इसलिए फ्लैट लोगो मोड अपने ग्रेडिएंट को पैच में बदल देगा: इसके बजाय कलर मोड का उपयोग करें @msg this kind of brush has no options dialog yet इस तरह के ब्रश में अभी तक कोई ऑप्शन्स संवाद नहीं है @msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it इस पोस्टस्क्रिप्ट फ़ाइल को पढ़ा नहीं जा सकता ({why}): इसे उस ऐप में PDF या SVG के रूप में सेव करें जिसने इसे बनाया @@ -8308,13 +8308,13 @@ antiAlias Sharp शार्प @msg tintReduction must be from 0 to 100 % tintReduction 0 से 100 % तक होना चाहिए @msg title can't be empty title खाली नहीं हो सकता @msg to (the new name) is required (नया नाम) के लिए आवश्यक है -@msg to must be fill or stroke to: होना चाहिए भरने या स्ट्रोक होना चाहिए -@msg to must be smooth or corner to: होना चाहिए चिकनी या कोने होना +@msg to must be fill or stroke to को fill या stroke होना चाहिए +@msg to must be smooth or corner to को smooth या corner होना चाहिए @msg too large for a zip archive: package to a folder एक ज़िप संग्रह के लिए बहुत बड़ा: एक फ़ोल्डर में पैकेज @msg too many `points` (at most 100000) बहुत सारे `points` (अधिकांश 100000 पर) @msg Too many breaks to remove at once एक बार में हटाने के लिए बहुत सारे ब्रेक @msg too many changed shortcuts बहुत से बदलाव किए गए शॉर्टकट -@msg too many characters: at most {MAX_CHARS} बहुत सारे अक्षर: at most {MAX_CHARS} +@msg too many characters: at most {MAX_CHARS} बहुत अधिक अक्षर: अधिकतम {MAX_CHARS} @msg too many dots for this size: lower the frequency इस आकार के लिए बहुत सारे डॉट्स: आवृत्ति को कम करें @msg too many filters over one file एक फ़ाइल पर बहुत सारे फिल्टर @msg too many library panels were opened in this session इस सत्र में कई लाइब्रेरी पैनल ओपन किए गए थे। @@ -8336,7 +8336,7 @@ antiAlias Sharp शार्प @msg two saved selections would be named `{_1}` दो सेव किए गए सिलेक्शन का नाम `{_1}` होगा @msg type dimension cannot be represented with this text transform इस टेक्स्ट को बदलने के साथ टाइप आयाम का प्रतिनिधित्व नहीं किया जा सकता है @msg type dimension must be finite टाइप का आयाम परिमित होना चाहिए -@msg type must be static or dynamic, not {v} type: प्रकार स्थिर या गतिशील होना चाहिए, न कि {v} +@msg type must be static or dynamic, not {v} type static या dynamic होना चाहिए, {v} नहीं @msg type objects can't be isolated टाइप ऑब्जेक्ट्स को आइसोलेट नहीं किया जा सकता @msg type that changes writing direction inside a text object opens in the direction of its first character टेक्स्ट ऑब्जेक्ट के भीतर लेखन दिशा बदलने वाला टेक्स्ट अपने पहले वर्ण की दिशा में खुलता है @msg type used as a mask in linked areas, wraparound text or a compound path doesn't clip लिंक्ड एरिया, रैपअराउंड टेक्स्ट या कंपाउंड पाथ में मास्क के रूप में इस्तेमाल किया गया टाइप क्लिप नहीं करता @@ -8344,15 +8344,15 @@ antiAlias Sharp शार्प @msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप @msg unexpected resources अप्रत्याशित संसाधन @msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति) -@msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (one, short or full) +@msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (none, short या full) @msg unknown {key} profile `{n}` अज्ञात {key} प्रोफ़ाइल `{n}` -@msg unknown align `{other}` अज्ञात `{other}` +@msg unknown align `{other}` अज्ञात संरेखण `{other}` @msg unknown alignToPath `{a}` अज्ञात alignToPath `{a}` @msg unknown arrowhead {v} अज्ञात arrowhead {v} @msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) अज्ञात artboardLayout कुंजी `{k}` (layout, columns, spacing, rightToLeft) @msg unknown attribute `{attribute}` अज्ञात विशेषता `{attribute}` @msg unknown attribute `{k}` अज्ञात विशेषता `{k}` -@msg unknown axis `{other}` (vertical|horizontal|free) अज्ञात धुरी `{other}` (vertical Chhorizontal Chlorfree) +@msg unknown axis `{other}` (vertical|horizontal|free) अज्ञात अक्ष `{other}` (vertical|horizontal|free) @msg unknown blend mode {b} अज्ञात मिश्रण मोड {b} @msg unknown blend mode `{b}` अज्ञात मिश्रण मोड `{b}` @msg unknown brush type `{o}` अज्ञात ब्रश प्रकार `{o}` @@ -8394,8 +8394,8 @@ antiAlias Sharp शार्प @msg unknown mode {m} अज्ञात मोड {m} @msg unknown mode `{o}` अज्ञात मोड `{o}` @msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) अज्ञात मोड `{other}` (ग्रे, RGB, hsb, लैब, cmyk या वेब) -@msg unknown mode `{other}` (mono|cmyk) अज्ञात मोड `{other}` (mono trumcmyk) -@msg unknown mode `{other}` (rgb|cmyk|gray|global) अज्ञात मोड `{other}` (rgb trumcmyk Chgray Chlobal) +@msg unknown mode `{other}` (mono|cmyk) अज्ञात मोड `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) अज्ञात मोड `{other}` (rgb|cmyk|gray|global) @msg unknown mode `{other}` (set, add, subtract) अज्ञात मोड `{other}` (सेट, ऐड, घटाना) @msg unknown model `{other}` अज्ञात मॉडल `{other}` @msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, नया या डॉक्यूमेंट) @@ -8420,9 +8420,9 @@ antiAlias Sharp शार्प @msg `{path}` must be {expected}, not {got} `{path}` {expected} होना चाहिए, {got} नहीं @msg `{path}` is nested too deeply `{path}` बहुत गहराई तक नेस्ट किया गया है @msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1}) -@msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (one, मानक या wedges) +@msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (none, standard या wedges) @msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड) -@msg unknown pieSort `{v}` (none, all or first) अज्ञात pieSort `{v}` (one, all या पहले) +@msg unknown pieSort `{v}` (none, all or first) अज्ञात pieSort `{v}` (none, all या first) @msg unknown plate `{n}` अज्ञात प्लेट `{n}` @msg unknown preference `{k}` अज्ञात वरीयता `{k}` @msg unknown preference `{key}` अज्ञात वरीयता `{key}` @@ -8443,19 +8443,19 @@ antiAlias Sharp शार्प @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}` (see webExport.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) अज्ञात प्रीसेट `{name}` (webExport.presets.list देखें) @msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) अज्ञात प्रीसेट `{name}`: एक अंतर्निहित या सेव किए गए प्रीसेट नहीं (imageTrace.presets देखें) -@msg unknown profile {id} (see stroke.widthProfile.list) अज्ञात प्रोफ़ाइल {id} (view stroke.widthProfile.list) +@msg unknown profile {id} (see stroke.widthProfile.list) अज्ञात प्रोफ़ाइल {id} (stroke.widthProfile.list देखें) @msg unknown proof target `{t}` अज्ञात प्रमाण लक्ष्य `{t}` @msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (डबल, सिंगल) @msg unknown resolution `{v}` अज्ञात रिज़ॉल्यूशन `{v}` @msg unknown rule `{r}` (see color.harmony) अज्ञात नियम `{r}` (देखें color.harmony) @msg unknown rule `{rule}` ({_1}) अज्ञात नियम `{rule}` ({_1}) @msg unknown setting `{k}` अज्ञात सेटिंग `{k}` -@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) अज्ञात आकार `{v}` (circle Chellipsesquareddiamond filline) -@msg unknown side `{other}` (left|right|top|bottom) अज्ञात पक्ष `{other}` (left |right |topbottom) -@msg unknown snap option `{key}` (use {options}) अज्ञात स्नैप ऑप्शन्स `{key}` (Us {options}) -@msg unknown spacing `{o}` (smooth|steps|distance) अज्ञात रिक्ति `{o}` (स्मोथ ट्रूस्टेप |distance) +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) अज्ञात आकार `{v}` (circle|ellipse|square|diamond|line) +@msg unknown side `{other}` (left|right|top|bottom) अज्ञात पक्ष `{other}` (left|right|top|bottom) +@msg unknown snap option `{key}` (use {options}) अज्ञात स्नैप ऑप्शन `{key}` ({options} का उपयोग करें) +@msg unknown spacing `{o}` (smooth|steps|distance) अज्ञात स्पेसिंग `{o}` (smooth|steps|distance) @msg unknown tileType `{t}` ({_1}) अज्ञात tileType `{t}` ({_1}) @msg unknown tool `{t}` अज्ञात टूल `{t}` @msg unknown tool `{tool}` अज्ञात टूल `{tool}` @@ -8494,11 +8494,11 @@ antiAlias Sharp शार्प @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files संस्करण {0} की फ़ाइलें संपीड़ित नहीं हो सकतीं: केवल संस्करण {COMPRESSED_SINCE} और बाद के संस्करण संपीड़ित फ़ाइलें खोलते हैं @msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio को 0.1 से 10 के बीच संख्या होना चाहिए @msg view is required view आवश्यक है -@msg view must be one of {_1} view: राय होना चाहिए {_1} +@msg view must be one of {_1} view {_1} में से एक होना चाहिए @msg warpStyle can only edit an applied Warp effect warpStyle केवल लागू वार्प इफ़ेक्ट एडिट कर सकता है @msg width and height are at least 1 pixel चौड़ाई और ऊंचाई कम से कम 1 पिक्सेल हैं @msg width factors must be finite चौड़ाई कारकों को परिमित होना चाहिए -@msg width must be positive width: चौड़ाई सकारात्मक होना चाहिए +@msg width must be positive width धनात्मक होनी चाहिए @msg width out of range सीमा से बाहर चौड़ाई @msg width/height must be > 0 चौड़ाई / ऊंचाई होना चाहिए > 0 @msg width and height must be more than 0 and at most {limit} चौड़ाई और ऊंचाई 0 से अधिक और अधिकतम {limit} होनी चाहिए @@ -8517,10 +8517,10 @@ antiAlias Sharp शार्प @msg a layer of the PSD file is too large to read PSD फ़ाइल की एक लेयर पढ़ने के लिए बहुत बड़ी है @msg the PSD file's layers take more memory to read than allowed PSD फ़ाइल की लेयर पढ़ने के लिए अनुमति से अधिक मेमोरी चाहिए @msg the layers can't be read ({e}): the file opened as its merged image लेयर पढ़ी नहीं जा सकतीं ({e}): फ़ाइल अपनी मर्ज की गई इमेज के रूप में खुली -@msg zone must be a string zone: क्षेत्र एक स्ट्रिंग होना चाहिए +@msg zone must be a string zone एक स्ट्रिंग होनी चाहिए @msg zone must be tab, center, left, right, top or bottom zone को tab, center, left, right, top या bottom होना चाहिए -@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone: जोन को एक एंकर की आवश्यकता होती है: एंकर के साथ एक पैनल के बगल में, या साइड (स्टार्ट या एंड) के साथ एक नए डॉक कॉलम में -@msg zoom must be positive zoom: ज़ूम सकारात्मक होना चाहिए +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone के लिए एंकर चाहिए: anchor देकर किसी पैनल के पास डॉक करें, या side (Start या End) देकर नए डॉक कॉलम में +@msg zoom must be positive zoom धनात्मक होना चाहिए @msgplural ({n} linked file not found)|({n} linked files not found) ({n} लिंक्ड फ़ाइल नहीं मिली)|({n} लिंक्ड फ़ाइलें नहीं मिलीं) @msgplural {n} color is out of the CMYK gamut|{n} colors are out of the CMYK gamut {n} रंग CMYK गैमट से बाहर है|{n} रंग CMYK गैमट से बाहर हैं @msgplural {n} font file is now in {folder}; {failed} couldn't be added: {reason}|{n} font files are now in {folder}; {failed} couldn't be added: {reason} {n} फ़ॉन्ट फ़ाइल अब {folder} में है; {failed} जोड़ी नहीं जा सकीं: {reason}|{n} फ़ॉन्ट फ़ाइलें अब {folder} में हैं; {failed} जोड़ी नहीं जा सकीं: {reason} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 54b967701d..b9ee2a1060 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -113,6 +113,59 @@ fn hindi_rows_keep_the_meaning_of_their_english() { assert!(all.contains("3 आर्टबोर्ड में से 1") && all.contains("सभी आर्टबोर्ड रखने के लिए"), "{all}"); let left_out = message(hi, "the file was read up to an error (bad xref): what follows it was left out"); assert!(left_out.contains("उसके बाद का हिस्सा छोड़ दिया गया"), "{left_out}"); + assert_eq!(message(hi, "unknown align `left`"), "अज्ञात संरेखण `left`"); + assert_eq!(tr(hi, "Delete Library Group"), "लाइब्रेरी ग्रुप हटाएँ"); + assert_eq!(message(hi, "not editing an opacity mask"), "ओपेसिटी मास्क संपादित नहीं किया जा रहा है"); + assert_eq!( + message(hi, "a page thumbnail must be RGB pixels, 1 to 256 a side (got 0 × 4)"), + "पेज थंबनेल RGB पिक्सल का होना चाहिए, हर तरफ़ 1 से 256 (मिला 0 × 4)" + ); + assert_eq!(message(hi, "invalid angle"), "अमान्य कोण"); +} + +/// Hindi messages keep every `a|b|c` list of accepted values exactly as their English writes it. +#[test] +fn hindi_messages_keep_their_lists_of_accepted_values() { + let hi = Lang::from_code("hi").expect("hi registered"); + let (entries, _) = parse_entries(hi.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg") { + let lists = src.split(|c: char| !(c.is_ascii_alphabetic() || c == '|')).filter(|w| w.contains('|') && w.split('|').all(|v| !v.is_empty())); + for list in lists.filter(|list| !translation.contains(*list)) { + wrong.push(format!("{list} in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Hindi messages change a list of values:\n{}", wrong.len(), wrong.join("\n")); +} + +/// Hindi rows leave no English connecting word in their sentences. Text in backticks, quotes, +/// braces and brackets is a name, value or placeholder and keeps its English. +#[test] +fn hindi_rows_keep_no_english_words_in_their_sentences() { + const ENGLISH: [&str; 13] = ["or", "and", "the", "must", "give", "see", "use", "most", "is", "of", "be", "not", "an"]; + let hi = Lang::from_code("hi").expect("hi registered"); + let (entries, _) = parse_entries(hi.0.source); + let mut wrong = Vec::new(); + for (_, src, translation) in &entries { + let mut prose = String::new(); + let mut close = None; + for c in translation.chars() { + match (close, c) { + (Some(end), _) if c == end => close = None, + (Some(_), _) => {} + (None, '`') => close = Some('`'), + (None, '"') => close = Some('"'), + (None, '“') => close = Some('”'), + (None, '{') => close = Some('}'), + (None, '[') => close = Some(']'), + (None, _) => prose.push(c), + } + } + if let Some(word) = prose.split(|c: char| !c.is_ascii_alphabetic()).find(|w| ENGLISH.contains(w)) { + wrong.push(format!("{word} in {src:?} -> {translation:?}")); + } + } + assert!(wrong.is_empty(), "{} Hindi rows keep an English word:\n{}", wrong.len(), wrong.join("\n")); } /// Locale tags name their language by region or script (in either case, with an encoding or diff --git a/docs/localization-hi.md b/docs/localization-hi.md index ad59a036a9..3d304a5d50 100644 --- a/docs/localization-hi.md +++ b/docs/localization-hi.md @@ -40,6 +40,13 @@ community. The stroke corner names (Bevel Join, Miter Join, Round Join) and the जॉइन. `hindi_rows_use_one_term_for_a_variable_and_for_joining_paths` checks both terms in every row. +A sentence keeps no English connecting word such as "or", "must" or "give": write या, होना +चाहिए and दें. Parameter names, enum values and command IDs stay in English, and a list of +accepted values such as `(left|right|top|bottom)` keeps the English's spelling and its `|` +separators. `hindi_rows_keep_no_english_words_in_their_sentences` checks every row outside +backticks, quotes, braces and brackets, and `hindi_messages_keep_their_lists_of_accepted_values` +checks every `@msg` row's lists. + ## Terminology | English | Hindi | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..af84c42adb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -870,6 +870,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Hindi wording (2026-11-13): rows outside the plug-in messages keep their English's meaning, quantities and clauses, and use the catalog's save, swatch, crop, resolution, path and type terms; a test checks those terms and the point unit in every row; native review remains; scores and estimates unchanged. - Hindi terms (2026-11-13): every row that names a variable (data merge, variable fonts and widths, environment variables) says वेरिएबल, and joining paths, anchor points, point type and lines says जोड़ें instead of शामिल हों, which stays for joining the community; a test checks both terms in every row; scores and estimates unchanged. + - Hindi English words (2026-11-13): 264 rows no longer keep English words in their sentences, repeat a parameter's name in Hindi or garble them, among them "unknown align", which now names the alignment; lists of accepted values such as `(left|right|top|bottom)` stay as their English writes them; two tests check every row; native review remains; scores and estimates unchanged. - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). From a2efb2d88b83213d8169ad8964ee92301e1cb6ae Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:23:42 -0800 Subject: [PATCH 24/27] Artboards: delete several artboards and their guides in one pass Delete Artboards, Delete Empty Artboards and Cut Artboard removed the chosen artboards one at a time, walking every ruler guide and the layer tree once per artboard. They now share one helper that removes the artboards in one walk and their guides in one more, leaving the same artboards, guides and selected guides as before. Empty Artboards in Artwork Cleanup had its own copy of the one-pass removal. It now calls the same helper, and its test reads that helper's walk counter. --- crates/engine/src/cmd.rs | 2 +- .../engine/src/cmd/addons/cleanup/checks.rs | 33 +------ crates/engine/src/cmd/addons/cleanup/tests.rs | 2 +- crates/engine/src/cmd/layer.rs | 47 ++++++++-- crates/engine/src/cmd/panelcmds.rs | 10 +-- crates/engine/src/tests/panelcmds.rs | 89 +++++++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 135 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 166436b0c8..94d8add68b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -46,7 +46,7 @@ pub mod importmatches; pub(crate) mod inline; mod interactivity; mod intertwine; -mod layer; +pub(crate) mod layer; mod layerpanel; pub mod library; pub mod linkfiles; diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 52b20cdddc..be32cf0a83 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect}; use vectorcraft_pathops::addons as po; use super::super::super::edit::outermost; -use super::super::super::layer::empty_artboards; +use super::super::super::layer::{empty_artboards, remove_artboards}; use crate::Result; /// Sine of the turn below which an anchor on a straight run is collinear. @@ -508,35 +508,6 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Out if empty.is_empty() { return Ok(Outcome::default()); } - // `empty` is in artboard order: one walk drops them and the others keep their order. - let mut gone: HashSet<u32> = HashSet::with_capacity(empty.len()); - let mut i = 0; - d.artboards.retain(|a| { - let keep = empty.binary_search(&i).is_err(); - i += 1; - if !keep { - gone.insert(a.id); - } - keep - }); - count_artboard_pass(); - for &id in &gone { - d.retire_artboard_id(id); - } - d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); - count_artboard_pass(); + remove_artboards(d, sel, &empty)?; Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) } - -#[cfg(test)] -thread_local! { - /// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this - /// thread. - pub(super) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; -} - -/// Tests: count one walk over the artboards or the ruler guides. -fn count_artboard_pass() { - #[cfg(test)] - ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); -} diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 2ccdce9dad..4d4ed6461b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -502,7 +502,7 @@ fn removing_many_selected_objects_looks_at_each_root_once_a_pass() { #[test] fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() { - use super::checks::ARTBOARD_PASSES; + use crate::cmd::layer::ARTBOARD_PASSES; let mut s = session(); rect(&mut s, 200.0, 20.0, 80.0, 60.0); let (keep, gone) = s diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f589ea62da..83f27cdd61 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -994,20 +994,53 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> { ok() } -/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left. -fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> { +/// Delete artboards `indices` (sorted, each once) of `d` with their guides; at least one must be +/// left. The other artboards keep their order, no later artboard takes a deleted one's id, and +/// `sel` forgets the deleted guides. One walk over the artboards and one over the ruler guides, +/// however many go. Delete Artboard, Delete Empty Artboards, Cut Artboard and Artwork Cleanup's +/// Empty Artboards. +pub(crate) fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> { + if indices.iter().any(|i| *i >= d.artboards.len()) { + return Err(EngineError::Other("no such artboard".into())); + } if indices.len() >= d.artboards.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); } - // The last first, so the others keep their places; their guides go with them. - for &i in indices.iter().rev() { - let id = d.artboards.remove(i).id; - d.retire_artboard_id(id); - d.retain_guides(sel, |g| g.artboard != Some(id)); + if indices.is_empty() { + return Ok(()); } + let mut gone: HashSet<u32> = HashSet::with_capacity(indices.len()); + let mut i = 0; + d.artboards.retain(|a| { + let keep = indices.binary_search(&i).is_err(); + i += 1; + if !keep { + gone.insert(a.id); + } + keep + }); + count_artboard_pass(); + for &id in &gone { + d.retire_artboard_id(id); + } + d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a))); + count_artboard_pass(); Ok(()) } +#[cfg(test)] +thread_local! { + /// Tests: how many walks [`remove_artboards`] made over the artboards and the ruler guides on + /// this thread. + pub(crate) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; +} + +/// Tests: count one walk over the artboards or the ruler guides. +fn count_artboard_pass() { + #[cfg(test)] + ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1); +} + /// The artboards Delete Empty Artboards deletes: those with no artwork /// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one /// of them (a document keeps one, as Delete Artboards keeps artboard 1). diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 5910435a75..6f4ba513f2 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -345,15 +345,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val let copied = clip.nodes.len(); if cut { s.edit("Cut Artboard", |d, sel| { - if indices.iter().any(|i| *i >= d.artboards.len()) { - return Err(EngineError::Other("no such artboard".into())); - } - // The last first, so the others keep their places; their guides go with them. - for &i in indices.iter().rev() { - let gone = d.artboards.remove(i).id; - d.retire_artboard_id(gone); - d.retain_guides(sel, |g| g.artboard != Some(gone)); - } + super::layer::remove_artboards(d, sel, &indices)?; d.remove_many(&art)?; sel.prune(d); Ok(()) diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index ac39de0646..8174cf36d1 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1683,6 +1683,95 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() { assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); } +/// `n` artboards in a row, each with a guide, and a guide on no artboard; every guide is selected, +/// and the key guide is the one on artboard `key`. +fn artboards_with_selected_guides(n: usize, key: usize) -> Session { + let mut s = row_of_artboards(n); + s.edit("selection fixture", |d, sel| { + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 75.0)); + sel.guides = d.ruler_guides().iter().filter_map(|g| g.id).collect(); + let on_key = d.artboards[key].id; + sel.key_guide = d.ruler_guides().iter().find(|g| g.artboard == Some(on_key)).and_then(|g| g.id); + Ok(()) + }) + .unwrap(); + s +} + +/// A document's artboards, ruler guides, selected guides and key guide. +type BoardsAndGuides = (Vec<vectorcraft_doc::Artboard>, Vec<vectorcraft_doc::Guide>, Vec<NodeId>, Option<NodeId>); + +/// The artboards, guides, selected guides and key guide after deleting artboards `indices` of the +/// open document one at a time, the last first. A clone shares the document's retired artboard id, +/// so the clone retires none. +fn deleted_one_at_a_time(s: &Session, indices: &[usize]) -> BoardsAndGuides { + let st = s.doc().unwrap(); + let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone()); + for &i in indices.iter().rev() { + let id = d.artboards.remove(i).id; + d.retain_guides(&mut sel, |g| g.artboard != Some(id)); + } + let guides = d.ruler_guides(); + (d.artboards, guides, sel.guides, sel.key_guide) +} + +/// The same as [`deleted_one_at_a_time`] for the open document as it is. +fn artboards_and_guides(s: &Session) -> BoardsAndGuides { + let st = s.doc().unwrap(); + (st.doc.artboards.clone(), st.doc.ruler_guides(), st.selection.guides.clone(), st.selection.key_guide) +} + +/// Delete Artboard with many artboards chosen walks the artboards once and the ruler guides once, +/// and leaves the same artboards, guides and selected guides as deleting them one at a time, in +/// one undo step. +#[test] +fn deleting_many_artboards_walks_the_artboards_and_guides_once() { + use crate::cmd::layer::ARTBOARD_PASSES; + let mut s = artboards_with_selected_guides(40, 7); + let indices: Vec<usize> = (0..40).filter(|i| i % 2 == 1 || *i == 38).collect(); + let expected = deleted_one_at_a_time(&s, &indices); + let steps = s.doc().unwrap().history.undo.len(); + ARTBOARD_PASSES.set(0); + s.execute("artboard.delete", &json!({ "indices": indices })).unwrap(); + assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides"); + let now = artboards_and_guides(&s); + assert_eq!(now, expected); + assert_eq!(now.0.len(), 19); + assert_eq!(now.1.len(), 20, "the kept artboards' guides and the guide on no artboard stay"); + assert_eq!((now.2.len(), now.3), (20, None), "the deleted guides leave the selection, the key guide with them"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 40); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 41); +} + +/// Delete Empty Artboards and Cut Artboard delete their artboards in the same single pass, with the +/// same result as deleting them one at a time. +#[test] +fn delete_empty_and_cut_artboards_walk_the_artboards_and_guides_once() { + use crate::cmd::layer::ARTBOARD_PASSES; + let mut s = artboards_with_selected_guides(30, 0); + box_at(&mut s, 4010.0, 20.0); + let empty: Vec<usize> = (0..30).filter(|i| *i != 10).collect(); + let expected = deleted_one_at_a_time(&s, &empty); + ARTBOARD_PASSES.set(0); + assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({ "deleted": empty })); + assert_eq!(ARTBOARD_PASSES.get(), 2); + assert_eq!(artboards_and_guides(&s), expected); + assert_eq!(artboard_names(&s), ["Artboard 11"]); + + let mut s = artboards_with_selected_guides(30, 4); + let cut: Vec<usize> = (2..28).collect(); + let expected = deleted_one_at_a_time(&s, &cut); + ARTBOARD_PASSES.set(0); + s.execute("artboard.cut", &json!({ "indices": cut, "art": false })).unwrap(); + assert_eq!(ARTBOARD_PASSES.get(), 2); + assert_eq!(artboards_and_guides(&s), expected); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4); + assert!(s.execute("artboard.cut", &json!({ "indices": [0, 1, 2, 3], "art": false })).is_err(), "a document keeps one artboard"); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4); +} + /// What counts as art: hidden and locked objects, an object straddling two artboards and one /// that only touches an artboard's edge; a group counts by its members, not by its box. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..e3a94f7544 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,6 +655,7 @@ since 2026-10-01. - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`. Delete Empty Artboards' enabled check measures each version of a document once, so the panel menu does not measure the document again on every frame while it is open. With an open path of 200,000 anchors across two artboards, the check takes under 0.001 ms on a later frame instead of 70.4 ms in a release build, and with 20,000 objects on two artboards it takes under 0.001 ms instead of 9.1 ms. Tests `delete_empty_artboards_check_measures_a_document_once`, `delete_empty_artboards_check_follows_each_change_to_the_document`, `delete_empty_artboards_check_follows_a_change_in_place`, `delete_empty_artboards_check_follows_a_document_switch` and `delete_empty_artboards_check_matches_measuring_after_varied_changes`. + Delete Artboards, Delete Empty Artboards and Cut Artboard remove all their artboards and those artboards' guides in one walk over the artboards and one over the ruler guides and layers, however many artboards go, instead of one walk over every guide and layer per artboard. The artboards, guides and selected guides left are the same as deleting the artboards one at a time. Tests `deleting_many_artboards_walks_the_artboards_and_guides_once` and `delete_empty_and_cut_artboards_walk_the_artboards_and_guides_once`. The Eyedropper, the Live Paint check for paint to sample, the Appearance panel's Characters chips and the colors the Libraries panel adds from the selection also read the proxy's paint without measuring the object; the Eyedropper measures it only to sample a gradient or a pattern, and the Libraries panel only for a gradient. The colors and paints are the same as before. With an open path of 200,000 anchors, the Live Paint check and the Eyedropper's sample for a point inside it take 0.004 ms together instead of 26.0 ms in a release build. Tests `samples_give_the_measured_proxy_paints_colors` and `colors_and_gradients_come_from_compound_paths_groups_and_colorized_objects`. - **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes. - **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. From 4bdfc44b26bae26fef21967c9437c9bc7694d56b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:24:38 -0800 Subject: [PATCH 25/27] Type: documents that define one composite font name differently each draw in their own A document's composite fonts were lent by writing them into the font database's shared map, and a second lend of another definition of the same name was skipped while the first was held. A document drawn in a tiled or floating window while the front document drew, a document placed in one that defines the name differently, and an export made while another document is in front could draw their type in the other document's definition. Registering the front document's fonts during a render could also change the definition mid-render. Lending now applies on the lending thread only: each guard pushes the document's definitions onto a per-thread stack that lookups read before the database's own fonts, latest loan first, and removes them when it drops, also when the thread unwinds. The guard is neither Send nor Sync. Lending no longer changes the database or its generation; while a thread lends definitions other than the database's, FontDb::generation gives a number of its own for them, so kept layouts and render caches made in one document's definitions are not used for another's. --- crates/render/src/tests/fontchange.rs | 54 ++++++ crates/text/src/fontdb.rs | 5 +- crates/text/src/fontdb/composite.rs | 226 +++++++++++++++----------- crates/text/src/fontdb/db.rs | 7 +- crates/text/src/tests/composite.rs | 109 ++++++++++--- docs/target-app-parity.md | 1 + 6 files changed, 284 insertions(+), 118 deletions(-) diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 0e7864b0b9..bedffb6b4b 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -210,6 +210,60 @@ fn a_documents_own_composite_font_draws_without_being_registered() { assert!(db.composite_font(NAME).is_none(), "given back after each render"); } +/// A placed document draws its type in its own definition of a composite font that the document +/// placing it defines differently. +#[test] +fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { + use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument}; + let _adding = adding_fonts(); + const NAME: &str = "CF Placed Twice"; + let definition = |roman: &str| { + let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); + parts.roman = CompositePart::new(roman, "Regular"); + CompositeFont { name: NAME.into(), parts } + }; + let typed = |family: &str| { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + // A page placing `d` (drawn exactly), defining `fonts`. + let page = |d: &Document, id: NodeId, key: &str, fonts: Vec<CompositeFont>| { + let blob = ImageBlob::new("application/json", key.as_bytes().to_vec()); + let p = PlacedDocument { + link: LinkInfo::new("/art/type.vectorcraft"), + key: key.into(), + bounding: false, + page_box: Default::default(), + width: 300.0, + height: 150.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + let art = d.node(id).unwrap().clone(); + vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0))); + let mut page = Document::new(300.0, 150.0); + page.composite_fonts.extend(fonts); + page.images.insert(key.into(), blob); + let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p))); + let l = page.layers[0].id; + page.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&page, 300, 150, Affine::IDENTITY, &opts).pixels + }; + let (mut placed, id) = typed(NAME); + placed.composite_fonts.push(definition("JetBrains Mono")); + let (plain, plain_id) = typed("JetBrains Mono"); + let (inter, inter_id) = typed("Inter"); + let own = page(&placed, id, "fontchange-placed-twice", vec![definition("Inter")]); + assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition"); + assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's"); + assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none()); +} + /// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes /// available: the glyph geometry kept for it is laid out again. #[test] diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index ace69a4148..f315f2bb69 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -116,10 +116,9 @@ pub struct FontDb { /// Faces instanced at the axis settings of type's Variable Font sliders ([`FontDb::instance`]). instances: Mutex<Instances>, /// Composite fonts (Type › Composite Fonts…) by lowercased name ([`FontDb::set_composite_font`]): - /// type naming one as its family is set in its parts' fonts. + /// type naming one as its family is set in its parts' fonts. A thread's guards can lend others + /// in their place ([`FontDb::lend_composite_fonts`]). composites: RwLock<HashMap<String, Arc<vectorcraft_doc::CompositeFont>>>, - /// The composite fonts [`FontDb::lend_composite_fonts`] put in, by lowercased name. - composite_loans: Mutex<HashMap<String, composite::CompositeLoan>>, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 0e1d9afa86..16e98038fa 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -2,6 +2,12 @@ //! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one //! in the font of the character's class ([`FontDb::composite_split`]). +use std::cell::RefCell; +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicU64, Ordering}; + use unicode_segmentation::UnicodeSegmentation; use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; @@ -11,6 +17,42 @@ use super::{Arc, FontDb, FontFace, FontMatch}; /// A face [`FontDb::resolve`] found and how it matched. type Resolved = Option<(Arc<FontFace>, FontMatch)>; +thread_local! { + /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the + /// latest loan last. + static LENT: RefCell<Vec<Loan>> = const { RefCell::new(Vec::new()) }; +} + +/// The number of the next loan. +static NEXT_LOAN: AtomicU64 = AtomicU64::new(1); + +/// The composite fonts one guard lends, on the thread that made it. +struct Loan { + /// The lending database's [`FontDb::id`]. + db: u64, + /// Tells this loan from the others. + number: u64, + /// The fonts, by lowercased name. + fonts: HashMap<String, Arc<CompositeFont>>, + /// The database's generation when they were lent, when each was then the font the database + /// holds by its name. + same_at: Option<u64>, + /// A number for the definitions lent: loans of the same definitions have the same one. + print: u64, +} + +/// Feed composite font `f`'s name and parts to `h`. +fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) { + f.name.hash(h); + let p = &f.parts; + for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] { + (&part.font, &part.style, part.scale_at_center).hash(h); + for v in [part.size, part.baseline, part.h_scale, part.v_scale] { + v.to_bits().hash(h); + } + } +} + /// How a composite font draws some text ([`FontDb::composite_coverage`]). #[derive(Clone, Debug)] pub struct CompositeCoverage { @@ -25,19 +67,13 @@ pub struct CompositeCoverage { impl FontDb { /// Make `font` available by its name, in place of a composite font of that name (any case). /// The font menus and anything else that lists fonts change only when the definition does. - /// Whether it did. + /// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays + /// in use on the guard's thread. pub fn set_composite_font(&self, font: &CompositeFont) -> bool { let key = font.name.to_lowercase(); { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { - // The same definition a guard lends: it stays when the guard drops. - let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(loan) = loans.get_mut(&key) - && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) - { - loan.adopted = true; - } return false; } map.insert(key, Arc::new(font.clone())); @@ -55,8 +91,16 @@ impl FontDb { removed } - /// The composite font named `name` (any case). + /// The composite font named `name` (any case): the one the latest guard of this thread that + /// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds. pub fn composite_font(&self, name: &str) -> Option<Arc<CompositeFont>> { + if self.lends() { + let key = name.to_lowercase(); + if let Some(f) = self.lent_font(&key) { + return Some(f); + } + return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned(); + } let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); if map.is_empty() { return None; @@ -64,6 +108,39 @@ impl FontDb { map.get(&name.to_lowercase()).cloned() } + /// Whether a guard of this thread lends composite fonts of this database. + fn lends(&self) -> bool { + LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false) + } + + /// The composite font named `key` (lowercased) that the latest guard of this thread lending that + /// name lends. + fn lent_font(&self, key: &str) -> Option<Arc<CompositeFont>> { + LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten() + } + + /// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each + /// font the guards of this thread lend is the one the database held by its name when it was lent + /// and the database has not changed since, else a number made from `g` and the definitions + /// lent, so what was laid out in one document's definitions is not taken for another's. + pub(super) fn lent_generation(&self, g: u64) -> u64 { + LENT.try_with(|l| { + let l = l.borrow(); + let loans = l.iter().filter(|loan| loan.db == self.id); + if loans.clone().all(|loan| loan.same_at == Some(g)) { + return g; + } + let mut h = DefaultHasher::new(); + g.hash(&mut h); + for loan in loans { + loan.print.hash(&mut h); + } + // The database's generations count up from 0 and never reach the top bit. + h.finish() | 1 << 63 + }) + .unwrap_or(g) + } + /// The face composite font `name` sets character `c` in (its class's part's), and whether /// that part's font is missing. `None` when `name` is no composite font. pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { @@ -168,48 +245,48 @@ impl FontDb { } } - /// Make each of the composite fonts `fonts` available while the returned guard lives, in place - /// of a composite font of the same name that the database holds. An app's session registers - /// the composite fonts of its front document; with the guard, a document exported without a - /// session, or one that is not in front, draws its type in its own. Dropping the guard puts - /// back the font each one replaced, or forgets it, unless another guard still lends the same - /// definition or a font of that name has been set since. While one guard lends a font, another - /// guard's different definition of that name is not lent. + /// Make each of the composite fonts `fonts` (a document's) available on this thread while the + /// returned guard lives, in place of a composite font of the same name that the database holds + /// or that an earlier guard of this thread lends. An app's session registers the composite fonts + /// of its front document; with the guard, a document exported without a session, one that is not + /// in front, or one placed in another document draws its type in its own, also while another + /// thread draws another document that defines the same name differently, or the session + /// registers another document's. Of fonts that share a name (in any case), the first is lent. + /// Other threads, and the fonts the database holds and lists, are left as they are. The guard + /// stays on its thread and ends its loan when it drops, also when the thread unwinds. pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> { - let mut lent = Vec::new(); - let mut changed = false; // A font a document can't use (its name an installed family's, or a value out of range) // is not lent: an app leaves it out when it opens the document. let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect(); - if !usable.is_empty() { - let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); - let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if usable.is_empty() { + return LentCompositeFonts { number: None, thread: PhantomData }; + } + let mut lent: HashMap<String, Arc<CompositeFont>> = HashMap::with_capacity(usable.len()); + let mut h = DefaultHasher::new(); + let mut same = true; + let generation = { + let map = self.composites.read().unwrap_or_else(|e| e.into_inner()); for f in usable { let key = f.name.to_lowercase(); - // A loan still in force: lend the same definition again, and leave another as it is. - if let Some(loan) = loans.get_mut(&key) - && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) - { - if *loan.font == *f { - loan.guards += 1; - lent.push((key, loan.font.clone())); + if lent.contains_key(&key) { + continue; + } + hash_font(f, &mut h); + let font = match map.get(&key) { + Some(have) if **have == *f => have.clone(), + _ => { + same = false; + Arc::new(f.clone()) } - continue; - } - if map.get(&key).is_some_and(|have| **have == *f) { - continue; - } - let font = Arc::new((*f).clone()); - let previous = map.insert(key.clone(), font.clone()); - loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); - lent.push((key, font)); - changed = true; + }; + lent.insert(key, font); } - } - if changed { - self.changed(); - } - LentCompositeFonts { db: self, lent } + self.generation.load(Ordering::Relaxed) + }; + let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed); + let loan = Loan { db: self.id, number, fonts: lent, same_at: same.then_some(generation), print: h.finish() }; + let pushed = LENT.try_with(|l| l.borrow_mut().push(loan)).is_ok(); + LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData } } /// The family and style composite font `f` sets characters of `class` in: its part's, or the @@ -219,7 +296,7 @@ impl FontDb { if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } } - /// The names of the composite fonts, sorted. + /// The names of the composite fonts the database holds (not those guards lend), sorted. pub fn composite_font_names(&self) -> Vec<String> { let mut v: Vec<String> = self.composites.read().unwrap_or_else(|e| e.into_inner()).values().map(|f| f.name.clone()).collect(); v.sort_by_key(|n| n.to_lowercase()); @@ -241,7 +318,7 @@ impl FontDb { /// its first character. Also the index of the run of `t` each run came from. `None` when no run /// is set in a composite font. pub fn composite_split(&self, t: &TextObject) -> Option<(TextObject, Vec<usize>)> { - if self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { + if !self.lends() && self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty() { return None; } let fonts: Vec<Option<Arc<CompositeFont>>> = @@ -299,59 +376,24 @@ impl FontDb { } } -/// A composite font [`FontDb::lend_composite_fonts`] put in the database. -pub(super) struct CompositeLoan { - /// The font lent. - font: Arc<CompositeFont>, - /// The font of that name it replaced, put back when the last guard lending it drops. - previous: Option<Arc<CompositeFont>>, - /// How many guards lend it. - guards: usize, - /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last - /// guard to drop then leaves it in place. - adopted: bool, -} - -/// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. +/// The composite fonts [`FontDb::lend_composite_fonts`] made available on its thread, until it +/// drops. It is neither `Send` nor `Sync`: the loan belongs to the thread that made it. #[must_use = "the fonts are given back when the guard drops"] pub struct LentCompositeFonts<'a> { - db: &'a FontDb, - /// The lowercased names this guard lends, each with the font lent. - lent: Vec<(String, Arc<CompositeFont>)>, + /// The loan's number, when it lends any font. + number: Option<u64>, + thread: PhantomData<(&'a FontDb, *const ())>, } impl Drop for LentCompositeFonts<'_> { fn drop(&mut self) { - if self.lent.is_empty() { - return; - } - let mut changed = false; - { - let mut map = self.db.composites.write().unwrap_or_else(|e| e.into_inner()); - let mut loans = self.db.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); - for (key, font) in &self.lent { - // A later loan of that name is another guard's. - let Some(loan) = loans.get_mut(key).filter(|l| Arc::ptr_eq(&l.font, font)) else { continue }; - loan.guards -= 1; - if loan.guards > 0 { - continue; - } - let previous = loan.previous.take(); - let adopted = loan.adopted; - loans.remove(key); - // Given back unless a font of that name has been set (the same definition included) - // or removed since. - if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { - match previous { - Some(p) => map.insert(key.clone(), p), - None => map.remove(key), - }; - changed = true; - } + let Some(number) = self.number else { return }; + // Fails only while the thread's own storage is being destroyed, which drops the loans. + let _ = LENT.try_with(|l| { + let mut l = l.borrow_mut(); + if let Some(at) = l.iter().rposition(|loan| loan.number == number) { + l.remove(at); } - } - if changed { - self.db.changed(); - } + }); } } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 0e28d57528..2fda0e79a1 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -74,7 +74,6 @@ impl FontDb { stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), composites: RwLock::new(HashMap::new()), - composite_loans: Mutex::new(HashMap::new()), } } @@ -127,9 +126,11 @@ impl FontDb { } /// A number that changes whenever the available fonts do (fonts added, system fonts - /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. + /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. While a + /// guard of this thread lends composite fonts other than the ones the database holds + /// ([`FontDb::lend_composite_fonts`]), it is a number of its own for those definitions. pub fn generation(&self) -> u64 { - self.generation.load(Ordering::Relaxed) + self.lent_generation(self.generation.load(Ordering::Relaxed)) } /// A number no other database made in this process has: with [`FontDb::generation`], it tells diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 729afa0d5a..4158d89330 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -101,9 +101,9 @@ fn ruby_over_composite_type_is_set_in_its_own_parts() { assert!(rise > 8.0 && rise < 15.0, "the reading is {rise} pt above the base's baseline"); } -/// Lent composite fonts are available while a guard lends them: a second guard lends one again, a -/// font of the same name the database holds is replaced and put back afterwards, and a font named -/// like an installed family is not lent. +/// Lent composite fonts are available while a guard lends them, in place of a font of the same name +/// the database holds, which the database keeps. A font named like an installed family is not +/// lent, and of two fonts of one name the first is. #[test] fn lent_composite_fonts_last_as_long_as_a_guard() { let db = FontDb::with_font_dirs(vec![]); @@ -115,6 +115,7 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { assert!(db.composite_font("lent").is_some()); assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 75.0); assert!(db.composite_font("Inter").is_none()); + assert_eq!(db.composite_font_names(), ["Held"], "the database's own fonts stay"); let second = db.lend_composite_fonts(&fonts); drop(first); assert!(db.composite_font("Lent").is_some()); @@ -122,29 +123,97 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { drop(second); assert!(db.composite_font("Lent").is_none()); assert_eq!(db.composite_font("Held").unwrap().parts.roman.size, 50.0); - // A font set in its place while lent stays when the guard drops. + // A font set in the database while one of that name is lent doesn't change the one in use, and + // is used once the guard drops. let lent = db.lend_composite_fonts(&fonts[..1]); let mut replaced = mixed("Lent"); replaced.parts.roman.size = 60.0; db.set_composite_font(&replaced); + assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 50.0); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); - // So does the same definition set while lent, after every guard lending it drops. - assert!(db.remove_composite_font("Lent")); - let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); - assert!(!db.set_composite_font(&fonts[0])); - drop(one); - drop(two); - assert!(db.composite_font("Lent").is_some()); - // A guard whose font was removed doesn't give back a later guard's loan of that name. - assert!(db.remove_composite_font("Lent")); - let old = db.lend_composite_fonts(&fonts[..1]); - assert!(db.remove_composite_font("Lent")); - let new = db.lend_composite_fonts(&fonts[..1]); - drop(old); - assert!(db.composite_font("Lent").is_some()); - drop(new); - assert!(db.composite_font("Lent").is_none()); + let mut other = mixed("LENT"); + other.parts.roman.size = 70.0; + let lent = db.lend_composite_fonts(&[mixed("Lent"), other]); + assert_eq!(db.composite_font("lent").unwrap().parts.roman.size, 50.0); + drop(lent); +} + +/// A composite font `name` whose Roman part is set at `size` percent. +fn roman_at(name: &str, size: f64) -> CompositeFont { + let mut f = mixed(name); + f.parts.roman.size = size; + f +} + +/// The advance of `A` set at `size` points in `family`. +fn advance(db: &FontDb, family: &str, size: f64) -> f64 { + layout(db, &TextObject::point(Point::ZERO, "A", style(family, size))).glyphs[0].advance +} + +/// Two documents that define one name differently each lay out in their own definition, also when +/// one's guard lends inside the other's (a document placed in another): the inner definition is +/// used until its guard drops, then the outer one again, also after a panic inside the inner one. +/// The generation tells the layouts apart, and lending changes nothing the database holds. +#[test] +fn nested_loans_of_one_name_use_each_documents_definition() { + let db = FontDb::with_font_dirs(vec![]); + let (a, b) = (roman_at("CF Shared", 50.0), roman_at("CF Shared", 75.0)); + db.set_composite_font(&a); + let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0)); + // Lending looks the name up among the installed families, which catalogs them the first time. + assert!(!db.has_installed_family("CF Shared")); + let plain = db.generation(); + let outer = db.lend_composite_fonts(std::slice::from_ref(&a)); + assert_eq!(db.generation(), plain, "the database's own definition"); + assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9); + { + let _inner = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert_eq!(db.composite_font("cf shared").unwrap().parts.roman.size, 75.0); + assert!((advance(&db, "CF Shared", 20.0) - three_quarters).abs() < 1e-9); + assert_ne!(db.generation(), plain, "another definition lays out under another generation"); + let elsewhere = + std::thread::scope(|s| s.spawn(|| (db.generation(), db.composite_font("CF Shared").unwrap().parts.roman.size)).join().unwrap()); + assert_eq!(elsewhere, (plain, 50.0), "other threads keep the database's"); + } + assert!((advance(&db, "CF Shared", 20.0) - half).abs() < 1e-9); + assert_eq!(db.generation(), plain); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _inner = db.lend_composite_fonts(std::slice::from_ref(&b)); + panic!("a render that fails"); + })); + assert!(caught.is_err()); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0); + drop(outer); + let next = db.lend_composite_fonts(std::slice::from_ref(&b)); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 75.0); + drop(next); + assert_eq!(db.composite_font("CF Shared").unwrap().parts.roman.size, 50.0); + assert_eq!(db.generation(), plain); +} + +/// Two threads that lend different definitions of one name at once each lay out in their own. +#[test] +fn threads_lending_one_name_at_once_use_their_own_definitions() { + let db = FontDb::with_font_dirs(vec![]); + let (half, three_quarters) = (advance(&db, "JetBrains Mono", 10.0), advance(&db, "JetBrains Mono", 15.0)); + let (both_lent, both_read) = (std::sync::Barrier::new(2), std::sync::Barrier::new(2)); + let lend = |size: f64| { + let _lent = db.lend_composite_fonts(&[roman_at("CF Threads", size)]); + both_lent.wait(); + let read = (advance(&db, "CF Threads", 20.0), db.generation()); + both_read.wait(); + read + }; + let ((a, ga), (b, gb)) = std::thread::scope(|s| { + let a = s.spawn(|| lend(50.0)); + let b = s.spawn(|| lend(75.0)); + (a.join().unwrap(), b.join().unwrap()) + }); + assert!((a - half).abs() < 1e-9, "{a} vs {half}"); + assert!((b - three_quarters).abs() < 1e-9, "{b} vs {three_quarters}"); + assert_ne!(ga, gb); + assert!(db.composite_font("CF Threads").is_none()); } /// The fonts a composite font sets text in are those of the parts its characters fall in, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..b85b26b389 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -662,6 +662,7 @@ since 2026-10-01. - **Colour emoji with bitmap glyphs:** `sbix` (Apple Color Emoji) and `CBDT` (Noto Color Emoji) glyphs draw as their largest strike's PNG, placed with the type and unpainted by its fill; PDF writes them in Type 3 glyphs or as images, SVG as images with outlined text, EPS and legacy `.ai` as images, and Create Outlines makes embedded images. Synthetic test fonts in `crates/text/src/test_fonts.rs`; tests `crates/text/src/tests/bitmaps.rs`, `crates/render/src/tests/bitmapglyphs.rs`, `crates/svg/tests/it/colour_glyphs.rs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, the `eps` `colour_bitmap_glyphs_*` tests and `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`. COLR and OpenType SVG fonts remain. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients and Porter-Duff compositing draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). +- **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). From 58cb261d7c8e8844211e78883b9cea00ac6d67cc Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:28:13 -0800 Subject: [PATCH 26/27] Global Edit: Start Global Edit is offered only for a group it can start on Select > Start Global Edit was enabled for any plain group, so a group holding type, an image or nothing was offered in the Select menu and the Properties panel's Quick Actions, the Control bar showed its Global Edit Options, and starting then refused it. The enabled check now applies the same support check as starting to every selected object, a group's contents included. The answer is kept per document revision and selected object, so the Control bar, which asks every frame, walks a group once per change. Docs: docs/mcp.md, docs/target-app-parity.md (a new line; the existing Global Edit line no longer says the menu item checks only for a group). --- crates/engine/src/cmd/globaledit.rs | 27 +++++--- crates/engine/src/doc_state.rs | 4 ++ crates/engine/src/tests/globaledit.rs | 95 +++++++++++++++++++++++++-- docs/mcp.md | 5 +- docs/target-app-parity.md | 3 +- 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 98f53b102e..6f125b52a5 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -432,16 +432,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; - // A group's contents are checked when Global Edit starts: measuring them here would walk - // every object in it each time the menu or the Properties panel asks. Other objects are - // measured ([`supported`]), not reduced to their form: the Control bar asks every frame. - match st.doc.node(*id) { - Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()), - Some(n) if supported(n) => Ok(()), - _ => Err(UNSUPPORTED.into()), - } + // The object, a group's contents included, is measured ([`supported`]), not reduced to its + // form, and the answer is kept until the document's revision moves: the Control bar asks + // every frame, and a group's walk takes time with the anchors in it. + let ok = match st.global_edit_support.get() { + Some((rev, key, ok)) if rev == st.revision && key == *id => ok, + _ => { + let ok = st.doc.node(*id).is_some_and(supported); + st.global_edit_support.set(Some((st.revision, *id, ok))); + ok + } + }; + if ok { Ok(()) } else { Err(UNSUPPORTED.into()) } } +/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found +/// for, and whether Global Edit supports it. +pub type SupportCache = std::cell::Cell<Option<(u64, NodeId, bool)>>; + fn toggle(s: &mut Session, p: &Value) -> Result<Value> { if s.doc()?.global_edit.is_some() { let st = s.doc_mut()?; @@ -989,8 +997,7 @@ mod tests { continue; } asked += 1; - let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n); - assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}"); + assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}"); match s.execute(C, &json!({})) { Ok(_) => { assert!(by_form(&n), "seed {seed}"); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 10d21266ba..ebd5e4120c 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -37,6 +37,9 @@ pub struct DocState { /// The objects Start Global Edit would take, highlighted before it starts /// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable. pub(crate) similar_outlines: cmd::globaledit::SimilarCache, + /// Whether Start Global Edit supports the one selected object + /// ([`cmd::globaledit::SupportCache`]), as of a revision. Not saved, not undoable. + pub(crate) global_edit_support: cmd::globaledit::SupportCache, /// What leaving symbol editing mode goes back to ([`cmd::symboledit`]). pub(crate) symbol_edit_return: cmd::symboledit::Return, pub interaction: Option<Interaction>, @@ -311,6 +314,7 @@ impl DocState { isolation: None, global_edit: None, similar_outlines: Default::default(), + global_edit_support: Default::default(), symbol_edit_return: Default::default(), interaction: None, undo_group: None, diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index ac5a494dc7..fa86248cdb 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -330,18 +330,103 @@ fn global_edit_needs_one_supported_object() { let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}"); assert!(s.doc().unwrap().global_edit.is_none()); - // A group holding type is refused, though another such group exists. + // A group holding type is not offered, though another such group exists. let group = |s: &mut Session, x: f64| { let p = rect(s, x, 100.0, 10.0, 10.0); let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); group_of(s, &[p, t]) }; let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0)); + assert_not_offered(&mut s, g, "a group holding type"); +} + +fn enabled(s: &Session, id: &str) -> bool { + command_specs().iter().find(|c| c.id == id).is_some_and(|c| (c.enabled)(s).is_ok()) +} + +/// With `id` selected, Start Global Edit is disabled, and running it changes nothing. +fn assert_not_offered(s: &mut Session, id: NodeId, what: &str) { + sel(s, &[id]); + assert!(!enabled(s, "select.globalEdit"), "{what}"); + let r = s.execute("select.globalEdit", &json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("symbol instances and groups of them")), "{what}: {r:?}"); + assert!(s.doc().unwrap().global_edit.is_none(), "{what}"); +} + +/// An embedded 4 × 4 image at `x`, in `parent` (the default layer when `None`) → its id. The +/// selection stays as it is. +fn image(s: &mut Session, x: f64, parent: Option<NodeId>) -> NodeId { + s.edit("Place", |d, _| { + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: Affine::translate((x, 0.0)) * Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(parent.or(d.default_layer()), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + Ok(id) + }) + .unwrap() +} + +#[test] +fn the_enabled_check_follows_edits_to_the_selected_group() { + let mut s = session(); + let (g, _) = card(&mut s, 0.0, 1.0); + card(&mut s, 300.0, 1.0); sel(&mut s, &[g]); - // The menu only checks that it is a group; starting refuses its contents. - let e = s.execute("select.globalEdit", &json!({})).unwrap_err(); - assert!(e.to_string().contains("symbol instances and groups of them"), "{e}"); - assert!(s.doc().unwrap().global_edit.is_none()); + assert!(enabled(&s, "select.globalEdit")); + // The answer is kept between frames, and an edit of the selected group changes it. + assert!(enabled(&s, "select.globalEdit")); + image(&mut s, 0.0, Some(g)); + assert_eq!(s.doc().unwrap().selection.objects, [g]); + assert!(!enabled(&s, "select.globalEdit"), "an image put into the selected group"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [g]); + assert!(enabled(&s, "select.globalEdit"), "after Undo"); +} + +#[test] +fn start_global_edit_is_offered_only_for_a_group_of_supported_objects() { + let mut s = session(); + // Groups holding an image, groups holding a group that holds type, and empty groups; two of + // each, so that each would have a group like it. + for x in [0.0, 300.0] { + let p = rect(&mut s, x, 100.0, 10.0, 10.0); + let i = image(&mut s, x, None); + let g = group_of(&mut s, &[p, i]); + assert_not_offered(&mut s, g, "a group holding an image"); + let (p, q) = (rect(&mut s, x, 200.0, 10.0, 10.0), rect(&mut s, x + 20.0, 200.0, 10.0, 10.0)); + let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let inner = group_of(&mut s, &[q, t]); + let g = group_of(&mut s, &[p, inner]); + assert_not_offered(&mut s, g, "a group holding a group that holds type"); + let empty = s + .edit("Group", |d, _| { + let id = d.alloc_id(); + d.insert(d.default_layer(), 0, Node::group(id, vec![]))?; + Ok(id) + }) + .unwrap(); + assert_not_offered(&mut s, empty, "an empty group"); + } + // A group of supported objects is offered and starts; while on, Stop is offered whatever is + // selected. + let p = rect(&mut s, 600.0, 400.0, 10.0, 10.0); + let t = NodeId(s.execute("text.create", &json!({"x": 600, "y": 450, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let typed = group_of(&mut s, &[p, t]); + let (g, _) = card(&mut s, 0.0, 1.0); + let (copy, _) = card(&mut s, 300.0, 1.0); + sel(&mut s, &[g]); + assert!(enabled(&s, "select.globalEdit")); + assert_eq!(start(&mut s, g, json!({})), [g, copy]); + sel(&mut s, &[typed]); + assert!(enabled(&s, "select.globalEdit"), "Stop"); + assert_eq!(s.execute("select.globalEdit", &json!({})).unwrap()["active"], false); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..e0ccfe3f06 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2664,7 +2664,10 @@ compound path or symbol instance selected, it finds the objects of the same shap ones), among those `select.all` would take and the objects in their groups, and returns `{active: true, count, ids}`, the selected object first (at most 10,000). With a group of such objects selected (nested groups and clipping paths included; not type or images), it finds the groups like it: as many objects, in the same order, each of the same -shape, lying the same way relative to each other (the group moved or scaled as a whole). `appearance` also asks for +shape, lying the same way relative to each other (the group moved or scaled as a whole). While Global Edit is off, +the command is disabled for any other selection, including a group that holds type, an image or nothing (it is off in +the Select menu, the Properties panel's Quick Actions do not show it, and the Control bar does not show its Global Edit +Options). `appearance` also asks for the same fill and stroke (for groups, of each object in them), `size` for the same width and height; `artboards` is `"all"` or a range such as `"1, 3-5"`, and `includeCanvas: false` leaves out the objects on no artboard. Missing params come from `select.globalEditOptions` (the Global Edit Options, kept for the session; it returns them with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..b602f626fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -627,9 +627,10 @@ since 2026-10-01. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. -- **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). The menu item checks only that the selection is a group; starting checks its contents. Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. +- **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. +- **Start Global Edit is offered only for a group it can start on:** with a group selected, Select › Start Global Edit is disabled, and the Properties panel's Quick Actions and the Control bar do not offer it or its options, when the group holds type, an image or nothing, at any depth; the check walks the group's objects the same way, stopping at the first one Global Edit does not take. This is VectorCraft's own rule. Regression test: `start_global_edit_is_offered_only_for_a_group_of_supported_objects` in `crates/engine/src/tests/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. From c8177dc7e8126abae6602b3c10cd21d37ec9e511 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Fri, 13 Nov 2026 13:33:01 -0800 Subject: [PATCH 27/27] Effects: a plug-in before a fill's or stroke's own 3D effect is told its fill or stroke A 3D effect on a fill or stroke worked on copies of the object's fills and strokes, and the copies were looked up among the object's items by identity, so a live-effect plug-in before that 3D effect was given `_context.item` "object" and a deferred stage there was listed with no item. The drawn art, the 3D meshes and Expand as Wireframes now pass the original item, so the plug-in is given `{"fill": i}` or `{"stroke": i}` as docs/plugins.md describes. The paints of a type's first run still have no item. --- crates/effects/src/solids.rs | 25 ++++++-- crates/effects/src/solids/wireframe.rs | 8 +-- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/solidcontext.rs | 74 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 crates/effects/src/tests/solidcontext.rs diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2642fca878..a68ff9529f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> { runs } +/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in +/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no +/// item of `n` and have none. +pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Vec<AppearanceItem>) { + let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + if own.is_empty() { + let paints = solid_paints(n); + return (vec![None; paints.len()], paints); + } + let paints = own.iter().map(|i| (*i).clone()).collect(); + (own.into_iter().map(Some).collect(), paints) +} + fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> { let view = view_options(p); match id { @@ -1771,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> { } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); let mut children = vec![]; let remaining: Vec<Effect>; if let Some((index, fx)) = solid(&n.appearance.effects) { @@ -1789,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> { crate::raster::follow_paints(&mut paints, &n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item))); let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc<Node>| { @@ -2086,16 +2099,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi } else { let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)? } else { let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?); } } diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 00e741bfe6..e6e7d89ab5 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet; use super::{ Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, + reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); let (path, rule) = object_profile(n, label)?; let ctx = GeomContext::of(n); - let paints = solid_paints(n); + let (sources, paints) = solid_paint_sources(n); if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]); } let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; - for item in &paints { + for (source, item) in sources.into_iter().zip(&paints) { if let Some((index, fx)) = solid(item.effects()) { - let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item))); let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 48665539d9..627cc1be03 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -8,6 +8,7 @@ mod imageoutline; mod painted; mod rasterfollow; mod scale; +mod solidcontext; mod transformfx; fn fx(id: &str, params: Value) -> Effect { diff --git a/crates/effects/src/tests/solidcontext.rs b/crates/effects/src/tests/solidcontext.rs new file mode 100644 index 0000000000..2de212dba1 --- /dev/null +++ b/crates/effects/src/tests/solidcontext.rs @@ -0,0 +1,74 @@ +//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it +//! is on, in the drawn art, the exported meshes and the wireframe. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId}; +use vectorcraft_geom::{PathData, Rect, shapes}; +use vectorcraft_plugins::wat as tpl; + +use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in}; + +/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks +/// for, `SMALL` otherwise. +const LARGE: f64 = 600.0; +const SMALL: f64 = 6.0; + +/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in +/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude & +/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise. +fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node { + let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w)); + let id = format!("test.effects.solidcontext.{name}"); + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#); + let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string()); + let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap(); + vectorcraft_plugins::registry::install_bytes(&module).unwrap(); + let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0); + appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))); + let effects = appearance.items[at].effects_mut(); + effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true }); + effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap()); + Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance) +} + +/// The width of the union of `paths`' bounds. +fn width(paths: impl IntoIterator<Item = PathData>) -> f64 { + paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width()) +} + +/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half +/// of `LARGE`, with the widths. +fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) { + let art = solid_art(n).unwrap(); + let mut paths = vec![]; + art.walk(&mut |c| paths.extend(c.path_data().cloned())); + let meshes = three_d_meshes_in(None, n).unwrap(); + let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0])); + let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min); + let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())]; + (widths.map(|w| w > LARGE / 2.0), widths) +} + +/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so +/// the art, meshes and wireframe are all built from the square it returns for that fill. +#[test] +fn a_plugin_before_a_fills_extrude_is_told_its_fill() { + let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`. +#[test] +fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() { + let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#)); + assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide"); +} + +/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one +/// extruded. +#[test] +fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() { + let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#)); + assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 167d99d3ab..4948ac869c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1873,6 +1873,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. +- **Live-effect plug-ins before a fill's or stroke's own 3D effect:** a live effect listed before a fill's or stroke's own Extrude & Bevel, Inflate or Rotate effect is given `_context.item` for that fill or stroke (`{"fill": i}` or `{"stroke": i}`), not `"object"`, in the drawn art, in the 3D meshes and in Expand as Wireframes; a deferred stage there is listed at that fill's or stroke's place. Regressions: `crates/effects/src/tests/solidcontext.rs`. Scores and hours unchanged. - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Gradient stop plates from filter plug-ins are clamped (G17):** the four process `plates` of a gradient stop in a filter's or importer's output are clamped to 0–1, as its other colors are; a value that is not a number becomes 0, and an infinite one becomes 0 or 1 by its sign. Painted live effects still refuse such a stop. Regressions: `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.