@pixerate/editor 0.190.0 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  loadOpenTypeFont,
3
- parseOpenTypeFont
4
- } from "../chunk-M7XIC2FC.js";
3
+ parseOpenTypeFont,
4
+ traceField
5
+ } from "../chunk-RQ2EM5TY.js";
5
6
  import {
6
7
  ADJUSTMENT_KEYS,
7
8
  ADJUSTMENT_KINDS,
@@ -383,7 +384,7 @@ import {
383
384
  writePsd,
384
385
  xyToTempTint,
385
386
  xyToUv
386
- } from "../chunk-7ZWLDVFH.js";
387
+ } from "../chunk-TXYXF32N.js";
387
388
  import {
388
389
  parseCssColor
389
390
  } from "../chunk-3CI7FRMK.js";
@@ -630,6 +631,7 @@ import {
630
631
  roundSubpaths,
631
632
  spliceRuns,
632
633
  styleAt,
634
+ subpathsToBezier,
633
635
  textIndexAt,
634
636
  translateMatrix,
635
637
  warpMeshColumns,
@@ -641,7 +643,9 @@ import {
641
643
  } from "../chunk-LPIKXPBM.js";
642
644
  import "../chunk-YXKLVD56.js";
643
645
  import "../chunk-M6DNZNVY.js";
644
- import "../chunk-WEIK3UYB.js";
646
+ import {
647
+ bezierPathToSvg
648
+ } from "../chunk-WEIK3UYB.js";
645
649
  import "../chunk-PWBZH3FK.js";
646
650
  import {
647
651
  EffectUnsupportedError,
@@ -2426,6 +2430,67 @@ function hexRgb(hex) {
2426
2430
  return [v >> 16 & 255, v >> 8 & 255, v & 255];
2427
2431
  }
2428
2432
 
2433
+ // src/image-editor/selection/path.ts
2434
+ function selectionToSubpaths(mask, options = {}) {
2435
+ const b = mask.bounds;
2436
+ if (!b) return [];
2437
+ const x0 = Math.max(0, b.x - 1);
2438
+ const y0 = Math.max(0, b.y - 1);
2439
+ const x1 = Math.min(mask.width, b.x + b.width + 1);
2440
+ const y1 = Math.min(mask.height, b.y + b.height + 1);
2441
+ const w = x1 - x0;
2442
+ const h = y1 - y0;
2443
+ const data = mask.data;
2444
+ const field = new Float32Array(w * h);
2445
+ for (let y = 0; y < h; y++) {
2446
+ const row = (y0 + y) * mask.width + x0;
2447
+ for (let x = 0; x < w; x++) field[y * w + x] = data[row + x] / 255;
2448
+ }
2449
+ const threshold = Math.min(254, Math.max(1, options.threshold ?? 128)) / 255;
2450
+ const subpaths = traceField(field, w, h, threshold, {
2451
+ tolerance: Math.max(0.1, options.tolerance ?? 0.5),
2452
+ cornerRoundness: options.cornerRoundness ?? 0.5,
2453
+ minArea: options.minArea ?? 4,
2454
+ squareCorners: true
2455
+ });
2456
+ return subpaths.map((sp) => mapSubpaths([sp], (p) => ({ x: p.x + x0, y: p.y + y0 }))[0]);
2457
+ }
2458
+ function subpathsToSvgPath(subpaths, precision = 2) {
2459
+ return bezierPathToSvg(subpathsToBezier(subpaths), { precision });
2460
+ }
2461
+ function pathToSelection(components, width, height, options = {}) {
2462
+ let x0 = Infinity;
2463
+ let y0 = Infinity;
2464
+ let x1 = -Infinity;
2465
+ let y1 = -Infinity;
2466
+ for (const c of components) {
2467
+ for (const sp of c.subpaths) {
2468
+ for (const a of sp.anchors) {
2469
+ for (const p of [a, a.in, a.out]) {
2470
+ if (!p) continue;
2471
+ x0 = Math.min(x0, p.x);
2472
+ y0 = Math.min(y0, p.y);
2473
+ x1 = Math.max(x1, p.x);
2474
+ y1 = Math.max(y1, p.y);
2475
+ }
2476
+ }
2477
+ }
2478
+ }
2479
+ const rx0 = Math.max(0, Math.floor(x0));
2480
+ const ry0 = Math.max(0, Math.floor(y0));
2481
+ const rx1 = Math.min(width, Math.ceil(x1));
2482
+ const ry1 = Math.min(height, Math.ceil(y1));
2483
+ if (!(rx1 > rx0 && ry1 > ry0)) return createSelectionMask(width, height);
2484
+ const rect = { x: rx0, y: ry0, width: rx1 - rx0, height: ry1 - ry0 };
2485
+ const cov = componentsCoverage(components, rect, options.fillRule ?? "nonzero");
2486
+ const hard = options.antiAlias === false;
2487
+ const bytes = new Uint8Array(cov.length);
2488
+ for (let i = 0; i < cov.length; i++) bytes[i] = hard ? cov[i] >= 0.5 ? 255 : 0 : coverageToByte(cov[i]);
2489
+ const mask = selectionFromRegion(width, height, rect, bytes);
2490
+ const feather = options.feather ?? 0;
2491
+ return feather > 0 && mask.bounds ? modifySelection(mask, "feather", feather) : mask;
2492
+ }
2493
+
2429
2494
  // src/image-editor/layers/shape-type-ui.ts
2430
2495
  var DEFAULT_SHAPE_TOOL_OPTIONS = {
2431
2496
  kind: "rect",
@@ -6273,21 +6338,61 @@ var ImageEditorController = class {
6273
6338
  * hiding everything) or `path` (reveals inside the path given). Replaces an existing vector mask;
6274
6339
  * the raster mask is kept and both apply. One undo step. Returns the vector mask.
6275
6340
  */
6276
- addVectorMask(id, from = "reveal-all", path = {}) {
6341
+ addVectorMask(id, from = "reveal-all", path = {}, trace2 = {}) {
6277
6342
  this.vectorMaskLayer(id);
6278
6343
  let vm;
6279
- if (from === "path") {
6344
+ if (from === "selection") {
6345
+ const subpaths = this.traceSelection(trace2);
6346
+ vm = { components: [{ op: "combine", subpaths }], fillRule: "evenodd" };
6347
+ } else if (from === "path") {
6280
6348
  vm = { components: this.vectorMaskComponents(path) };
6281
6349
  if (path.fillRule === "evenodd") vm.fillRule = "evenodd";
6282
6350
  } else if (from === "reveal-all" || from === "hide-all") {
6283
6351
  if (path.components?.length || path.shape !== void 0 || path.subpaths !== void 0) throw new LayerOperationError(`A ${from} vector mask starts empty; use from path to give a path.`);
6284
6352
  vm = { components: [], ...from === "reveal-all" ? { inverted: true } : {} };
6285
6353
  } else {
6286
- throw new LayerOperationError(`Unknown vector mask source "${String(from)}". Use reveal-all, hide-all or path.`);
6354
+ throw new LayerOperationError(`Unknown vector mask source "${String(from)}". Use reveal-all, hide-all, path or selection.`);
6287
6355
  }
6288
6356
  this.commitLayers(mapLayer(this.state.layers, id, (l) => ({ ...l, vectorMask: vm })));
6289
6357
  return vm;
6290
6358
  }
6359
+ /** The selection's outline as subpaths; throws when nothing is selected or nothing is left to trace. */
6360
+ traceSelection(options) {
6361
+ const sel = this.state.selection;
6362
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select first, then make a path from the selection.");
6363
+ if (options.tolerance !== void 0 && !(options.tolerance >= 0.1 && options.tolerance <= 10)) throw new LayerOperationError("tolerance must be from 0.1 to 10 px.");
6364
+ const subpaths = selectionToSubpaths(sel, options);
6365
+ if (!subpaths.length) throw new LayerOperationError("The selection is too small or too faint to trace (below minArea, or under half selected); lower minArea or threshold.");
6366
+ return subpaths;
6367
+ }
6368
+ /**
6369
+ * Make Work Path: the selection's outline as closed Bézier subpaths in document pixels, holes
6370
+ * included (fill them even-odd), within `tolerance` px. Changes nothing. Throws without a selection.
6371
+ */
6372
+ selectionToPath(options = {}) {
6373
+ const subpaths = this.traceSelection(options);
6374
+ const b = vectorMaskBounds({ components: [{ op: "combine", subpaths }] });
6375
+ const r = (n) => Math.round(n * 100) / 100;
6376
+ return {
6377
+ subpaths: roundSubpaths(subpaths),
6378
+ svg: subpathsToSvgPath(subpaths),
6379
+ fillRule: "evenodd",
6380
+ bounds: b ? { x: r(b.x), y: r(b.y), width: r(b.width), height: r(b.height) } : null,
6381
+ anchors: subpaths.reduce((n, sp) => n + sp.anchors.length, 0)
6382
+ };
6383
+ }
6384
+ /**
6385
+ * Make Selection: fills a path (`shape`, `subpaths` or `components`, document pixels) into a
6386
+ * selection combined with the current one by `mode`, with `feather` px and `antiAlias`. One
6387
+ * undo step. Returns whether anything is selected.
6388
+ */
6389
+ pathToSelection(path, options = {}) {
6390
+ if (options.feather !== void 0 && !(options.feather >= 0 && options.feather <= 1e3)) throw new LayerOperationError("feather must be from 0 to 1000 px.");
6391
+ const components = this.vectorMaskComponents(path);
6392
+ const { width, height } = this.naturalSize;
6393
+ const mask = pathToSelection(components, width, height, { ...options, fillRule: path.fillRule ?? options.fillRule });
6394
+ return this.setSelection(mask, options.mode ?? "replace");
6395
+ }
6291
6396
  /** Replaces a vector mask's path (its properties are kept). One undo step. Returns the vector mask. */
6292
6397
  setVectorMaskPath(id, path) {
6293
6398
  const { vm } = this.requireVectorMask(id);
@@ -9241,6 +9346,62 @@ var ImageEditorController = class {
9241
9346
  * text wrapped in `box`. Refused without a text shaper. Returns its id.
9242
9347
  */
9243
9348
  addTextLayer(init) {
9349
+ const layer = this.layoutTypeLayer(init);
9350
+ if (init.opacity !== void 0 && !(Number.isFinite(init.opacity) && init.opacity >= 0 && init.opacity <= 1)) throw new LayerOperationError("opacity must be between 0 and 1.");
9351
+ if (init.blend !== void 0 && !isBlendMode(init.blend)) throw new LayerOperationError(`Unknown blend mode "${String(init.blend)}".`);
9352
+ if (init.opacity !== void 0) layer.opacity = init.opacity;
9353
+ if (init.blend !== void 0) layer.blend = init.blend;
9354
+ const next = placeLayer(this.state.layers, layer, this.newLayerPlacement(init));
9355
+ this.commitLayers(next, () => {
9356
+ this.state.activeLayerId = layer.id;
9357
+ this.state.activeTarget = "pixels";
9358
+ });
9359
+ return layer.id;
9360
+ }
9361
+ /**
9362
+ * Type Mask (horizontal or vertical): a selection in the shape of `text`, laid out and drawn as
9363
+ * `addTextLayer` would place it (font, size, tracking, box, rotation), combined with the current
9364
+ * selection by `mode`. Vertical type stacks the characters top to bottom, centred on `x`. No
9365
+ * layer is added. One undo step. Returns whether anything is selected.
9366
+ */
9367
+ selectTextShape(init) {
9368
+ const vertical = init.orientation === "vertical";
9369
+ if (init.orientation !== void 0 && init.orientation !== "horizontal" && !vertical) throw new LayerOperationError("orientation must be horizontal or vertical.");
9370
+ if (init.feather !== void 0 && !(init.feather >= 0 && init.feather <= 1e3)) throw new LayerOperationError("feather must be from 0 to 1000 px.");
9371
+ let text = normalizeText(init.text);
9372
+ if (!text.trim()) throw new LayerOperationError("The text is empty, so there is nothing to select; give some characters.");
9373
+ let paragraph = init.paragraph;
9374
+ if (vertical) {
9375
+ if (/\n/.test(text)) throw new LayerOperationError("Vertical type masks take one column; select each column separately (mode add) instead of line breaks.");
9376
+ if (init.box) throw new LayerOperationError("Vertical type masks are point text; leave out box.");
9377
+ text = Array.from(text).join("\n");
9378
+ paragraph = { align: "center", ...paragraph ?? {} };
9379
+ }
9380
+ const layer = this.layoutTypeLayer({ ...init, text, paragraph });
9381
+ const { width, height } = this.naturalSize;
9382
+ const b = contentBounds(layer.surface);
9383
+ const x0 = Math.max(0, b?.x ?? 0);
9384
+ const y0 = Math.max(0, b?.y ?? 0);
9385
+ const x1 = Math.min(width, b ? b.x + b.width : 0);
9386
+ const y1 = Math.min(height, b ? b.y + b.height : 0);
9387
+ let mask;
9388
+ if (!b || x1 <= x0 || y1 <= y0) mask = createSelectionMask(width, height);
9389
+ else {
9390
+ const rect = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
9391
+ const rgba = readRegion(layer.surface, rect);
9392
+ const bytes = new Uint8Array(rect.width * rect.height);
9393
+ const hard = init.antiAlias === false;
9394
+ for (let i = 0; i < bytes.length; i++) {
9395
+ const a = rgba[i * 4 + 3];
9396
+ bytes[i] = hard ? a >= 128 ? 255 : 0 : a;
9397
+ }
9398
+ mask = selectionFromRegion(width, height, rect, bytes);
9399
+ }
9400
+ if ((init.feather ?? 0) > 0 && mask.bounds) mask = modifySelection(mask, "feather", init.feather);
9401
+ return this.setSelection(mask.bounds ? mask : null, init.mode ?? "replace");
9402
+ }
9403
+ /** A type layer laid out and drawn from `init` (not added): the shared part of `addTextLayer` and the type masks. */
9404
+ layoutTypeLayer(init) {
9244
9405
  const text = normalizeText(init.text);
9245
9406
  if (!Number.isFinite(init.x) || !Number.isFinite(init.y)) throw new LayerOperationError("x and y must be numbers (document pixels).");
9246
9407
  const style = patchTextStyle({ ...DEFAULT_TEXT_STYLE, color: this.state.paint.foreground }, init.style ?? {});
@@ -9252,19 +9413,9 @@ var ImageEditorController = class {
9252
9413
  frame = { kind: "box", width, height };
9253
9414
  }
9254
9415
  if (init.rotation !== void 0 && !Number.isFinite(init.rotation)) throw new LayerOperationError("rotation must be a number of degrees.");
9255
- if (init.opacity !== void 0 && !(Number.isFinite(init.opacity) && init.opacity >= 0 && init.opacity <= 1)) throw new LayerOperationError("opacity must be between 0 and 1.");
9256
- if (init.blend !== void 0 && !isBlendMode(init.blend)) throw new LayerOperationError(`Unknown blend mode "${String(init.blend)}".`);
9257
9416
  const t = [1, 0, 0, 1, init.x, init.y];
9258
9417
  const transform = init.rotation ? multiplyMatrices(t, rotateMatrix(init.rotation, { x: init.x, y: init.y })) : t;
9259
- const layer = createTypeLayer({ text, runs: [{ length: text.length, style }], paragraphs: [{ length: text.length, style: paragraph }], frame, transform, name: init.name ?? typeLayerName(text) }, this.textEnv());
9260
- if (init.opacity !== void 0) layer.opacity = init.opacity;
9261
- if (init.blend !== void 0) layer.blend = init.blend;
9262
- const next = placeLayer(this.state.layers, layer, this.newLayerPlacement(init));
9263
- this.commitLayers(next, () => {
9264
- this.state.activeLayerId = layer.id;
9265
- this.state.activeTarget = "pixels";
9266
- });
9267
- return layer.id;
9418
+ return createTypeLayer({ text, runs: [{ length: text.length, style }], paragraphs: [{ length: text.length, style: paragraph }], frame, transform, name: init.name ?? typeLayerName(text) }, this.textEnv());
9268
9419
  }
9269
9420
  /**
9270
9421
  * Replaces text [start, end) (default all of it) with `text`; inserted text takes the style at
@@ -11150,7 +11301,7 @@ var DEPTH_MASK_CONTROLS = [
11150
11301
  // src/image-editor/playback.ts
11151
11302
  var IMAGE_PLAYBACK_KIND = "layer";
11152
11303
  var PAINT = /^(paint_stroke|clone_stamp|heal_brush|spot_heal|dodge|burn|sponge|smudge|blur_stroke|sharpen_stroke|history_brush|set_clone_source|set_clone_transform|fill_pixels|clear_pixels|set_paint|set_retouch_options|load_mixer_brush|clean_mixer_brush)$/;
11153
- var SELECT = /^(select_region|select_subject|select_by_color|select_all|deselect|invert_selection|select_rect|select_ellipse|select_polygon|select_line|magic_wand|grow_selection|select_similar|reselect|select_layer_transparency|select_color_range|load_selection_from_image|load_selection_from_mask|set_selection_display|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy|stroke_selection|content_aware_fill|copy_pixels|cut_pixels|paste_pixels|save_selection|load_selection_from_channel|set_channel_properties|duplicate_channel|delete_channel|load_channel_from_image|set_quick_mask)$/;
11304
+ var SELECT = /^(select_region|select_subject|select_by_color|select_all|deselect|invert_selection|select_rect|select_ellipse|select_polygon|select_line|magic_wand|grow_selection|select_similar|reselect|select_layer_transparency|select_color_range|load_selection_from_image|load_selection_from_mask|set_selection_display|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy|stroke_selection|content_aware_fill|copy_pixels|cut_pixels|paste_pixels|save_selection|load_selection_from_channel|set_channel_properties|duplicate_channel|delete_channel|load_channel_from_image|set_quick_mask|path_to_selection|select_text_shape)$/;
11154
11305
  var ANNOTATE = /^(add_shape_layer|update_shape|add_shape_component|set_shape_component|remove_shape_component|merge_shape_components|add_text_layer|set_text|set_text_style|set_paragraph_style|set_text_frame|convert_text_to_shape)$/;
11155
11306
  var CROP = /^(set_crop|crop_to_selection|reset_crop|set_rotation|flip_image|transform_layer|warp_layer|flip_layer|offset_layer)$/;
11156
11307
  var ADJUST = /^(set_adjustments|add_adjustment_layer|update_adjustment_layer|remove_adjustment_layer|move_adjustment_layer)$/;
@@ -11271,7 +11422,9 @@ var SELECTION_TOOLS = [
11271
11422
  { id: "column", label: "Single Column" },
11272
11423
  { id: "lasso", label: "Lasso" },
11273
11424
  { id: "polygon", label: "Polygonal Lasso" },
11274
- { id: "range", label: "Color Range" }
11425
+ { id: "range", label: "Color Range" },
11426
+ { id: "type", label: "Type Mask" },
11427
+ { id: "type-vertical", label: "Vertical Type Mask" }
11275
11428
  ];
11276
11429
  var COLOR_RANGE_PRESET_LABELS = {
11277
11430
  reds: "Reds",
@@ -11302,7 +11455,10 @@ var DEFAULT_SELECTION_TOOL_OPTIONS = {
11302
11455
  feather: 0,
11303
11456
  fuzziness: 40,
11304
11457
  localized: 0,
11305
- rangeInvert: false
11458
+ rangeInvert: false,
11459
+ typeText: "Type",
11460
+ typeSize: 72,
11461
+ typeFamily: "sans-serif"
11306
11462
  };
11307
11463
  function selectionModeFromModifiers(modifiers, fallback) {
11308
11464
  if (modifiers.shiftKey && modifiers.altKey) return "intersect";
@@ -13684,6 +13840,24 @@ var SelectionGesture = class {
13684
13840
  }
13685
13841
  return;
13686
13842
  }
13843
+ if (options.tool === "type" || options.tool === "type-vertical") {
13844
+ this.start = null;
13845
+ try {
13846
+ this.controller.selectTextShape({
13847
+ text: options.typeText || "Type",
13848
+ x: point.x,
13849
+ y: point.y,
13850
+ orientation: options.tool === "type-vertical" ? "vertical" : "horizontal",
13851
+ style: { fontSize: options.typeSize ?? 72, fontFamily: options.typeFamily || "sans-serif" },
13852
+ mode: selectionModeFromModifiers(modifiers, options.mode),
13853
+ feather: options.feather ?? 0,
13854
+ antiAlias: options.antiAlias
13855
+ });
13856
+ } catch (err) {
13857
+ this.error = err instanceof Error ? err.message : String(err);
13858
+ }
13859
+ return;
13860
+ }
13687
13861
  if (options.tool === "wand" || options.tool === "range") {
13688
13862
  this.start = null;
13689
13863
  const common = {
@@ -15917,6 +16091,52 @@ function createImageShapeTools(controller) {
15917
16091
  fillRule: { type: "string", enum: ["nonzero", "evenodd"], description: "Winding rule within a component (default nonzero)." }
15918
16092
  };
15919
16093
  const LAYER = { type: "string", minLength: 1, description: "The layer (see list_layers). Not a shape layer: its path already is its vector mask." };
16094
+ const TRACE_PROPS = {
16095
+ tolerance: { type: "number", minimum: 0.1, maximum: 10, description: "How far (px) the path may stray from the selection edge (default 0.5; lower is tighter, more anchors)." },
16096
+ cornerRoundness: { type: "number", minimum: 0, maximum: 1, description: "0: straight sides and sharp corners; 1: as smooth as the outline allows (default 0.5)." },
16097
+ minArea: { type: "number", minimum: 0, description: "Islands and holes smaller than this many square px are left out (default 4)." }
16098
+ };
16099
+ const TRACE_KEYS = ["tolerance", "cornerRoundness", "minArea"];
16100
+ const traceOptions = (args) => {
16101
+ const o = {};
16102
+ for (const k of TRACE_KEYS) if (typeof args[k] === "number") o[k] = args[k];
16103
+ return o;
16104
+ };
16105
+ const selectionToPath = {
16106
+ name: "selection_to_path",
16107
+ title: "Make a path from the selection",
16108
+ description: "Make Work Path: traces the selection's outline (where it is at least half selected) into closed B\xE9zier subpaths in document pixels, holes included, within tolerance px. Changes nothing. Returns svg (compact SVG path data) and fillRule evenodd, ready for add_vector_mask, set_vector_mask_path or add_shape_layer (pass the svg as subpaths and fillRule evenodd), plus bounds and the anchor count. Use add_vector_mask from selection to do it in one step.",
16109
+ inputSchema: { type: "object", properties: { ...TRACE_PROPS, anchors: { type: "boolean", description: "Also return the subpaths as anchors with handles (default false: svg only)." } }, additionalProperties: false },
16110
+ annotations: { title: "Make a path from the selection", ...READ10 },
16111
+ handler: (args) => {
16112
+ const out = controller.selectionToPath(traceOptions(args));
16113
+ const { subpaths, ...rest } = out;
16114
+ return args.anchors === true ? out : rest;
16115
+ }
16116
+ };
16117
+ const pathToSelection2 = {
16118
+ name: "path_to_selection",
16119
+ title: "Make a selection from a path",
16120
+ description: "Make Selection: fills a path (a live shape, free subpaths or SVG path data, or components, in document pixels, as add_shape_layer takes) into a selection, combined with the current one by mode, with feather px and antiAlias. Use it to select along B\xE9zier curves, or to turn a path from selection_to_path or get_vector_mask back into a selection. One undo step. Returns the selection.",
16121
+ inputSchema: {
16122
+ type: "object",
16123
+ $defs: DEFS2,
16124
+ properties: {
16125
+ ...PATH_PROPS,
16126
+ feather: { type: "number", minimum: 0, maximum: 1e3, description: "Feather radius in px (default 0)." },
16127
+ antiAlias: { type: "boolean", description: "Soft edges (default true); false makes every pixel fully in or out." },
16128
+ mode: { type: "string", enum: ["replace", "add", "subtract", "intersect"], description: "How it combines with the current selection (default replace)." }
16129
+ },
16130
+ additionalProperties: false
16131
+ },
16132
+ annotations: { title: "Make a selection from a path", ...WRITE8 },
16133
+ handler: (args) => {
16134
+ const mode = args.mode;
16135
+ controller.pathToSelection(pathSpec(args), { feather: args.feather, antiAlias: args.antiAlias, mode });
16136
+ const sel = controller.getSelection();
16137
+ return { empty: !sel, bounds: sel?.bounds ?? null, area: Math.round(sel ? selectionArea(sel) : 0) };
16138
+ }
16139
+ };
15920
16140
  const getVectorMask = {
15921
16141
  name: "get_vector_mask",
15922
16142
  title: "Read a vector mask",
@@ -15928,14 +16148,15 @@ function createImageShapeTools(controller) {
15928
16148
  const addVectorMask = {
15929
16149
  name: "add_vector_mask",
15930
16150
  title: "Add a vector mask",
15931
- description: "Layer \u203A Vector Mask: a path that reveals the layer inside it and stays sharp at any size, on top of any raster mask (both apply). from path takes a live shape, free subpaths or components like add_shape_layer; reveal-all and hide-all start with an empty path. Use it for hard-edged geometric or traced cutouts that must stay editable; use add_layer_mask for soft or painted ones. A linked vector mask moves and transforms with the layer. Replaces an existing vector mask. One undo step. Returns the vector mask.",
16151
+ description: "Layer \u203A Vector Mask: a path that reveals the layer inside it and stays sharp at any size, on top of any raster mask (both apply). from path takes a live shape, free subpaths or components like add_shape_layer; from selection traces the current selection (within tolerance px); reveal-all and hide-all start with an empty path. Use it for hard-edged geometric or traced cutouts that must stay editable; use add_layer_mask for soft or painted ones. A linked vector mask moves and transforms with the layer. Replaces an existing vector mask. One undo step. Returns the vector mask.",
15932
16152
  inputSchema: {
15933
16153
  type: "object",
15934
16154
  $defs: DEFS2,
15935
16155
  properties: {
15936
16156
  layerId: LAYER,
15937
- from: { type: "string", enum: ["path", "reveal-all", "hide-all"], description: "path (default when a shape, subpaths or components are given), reveal-all or hide-all (an empty path)." },
15938
- ...PATH_PROPS
16157
+ from: { type: "string", enum: ["path", "selection", "reveal-all", "hide-all"], description: "path (default when a shape, subpaths or components are given), selection (trace the current selection), reveal-all or hide-all (an empty path)." },
16158
+ ...PATH_PROPS,
16159
+ ...TRACE_PROPS
15939
16160
  },
15940
16161
  required: ["layerId"],
15941
16162
  additionalProperties: false
@@ -15946,7 +16167,8 @@ function createImageShapeTools(controller) {
15946
16167
  const spec = pathSpec(args);
15947
16168
  const hasPath = spec.components?.length || spec.shape !== void 0 || spec.subpaths !== void 0;
15948
16169
  const from = args.from ?? (hasPath ? "path" : "reveal-all");
15949
- controller.addVectorMask(id, from, spec);
16170
+ if (from !== "selection" && TRACE_KEYS.some((k) => args[k] !== void 0)) throw new Error("tolerance, cornerRoundness and minArea apply only to from selection.");
16171
+ controller.addVectorMask(id, from, spec, traceOptions(args));
15950
16172
  return { vectorMask: controller.getVectorMask(id) };
15951
16173
  }
15952
16174
  };
@@ -16010,7 +16232,7 @@ function createImageShapeTools(controller) {
16010
16232
  return { layerId: id, vectorMask: null };
16011
16233
  }
16012
16234
  };
16013
- return [get, add2, update, setComponent, addComponent, removeComponent, merge, getVectorMask, addVectorMask, setVectorMaskPath, setVectorMaskProps, rasterizeVectorMask, removeVectorMask];
16235
+ return [get, add2, update, setComponent, addComponent, removeComponent, merge, getVectorMask, addVectorMask, setVectorMaskPath, setVectorMaskProps, rasterizeVectorMask, removeVectorMask, selectionToPath, pathToSelection2];
16014
16236
  }
16015
16237
 
16016
16238
  // src/image-editor/type-tools.ts
@@ -16180,7 +16402,43 @@ function createImageTypeTools(controller) {
16180
16402
  return { layer: layerSummary(id), shape: controller.getShapeLayer(id), warnings };
16181
16403
  }
16182
16404
  };
16183
- return [listFonts, get, add2, setText, setStyle, setParagraph, setFrame, toShape];
16405
+ const typeMask = {
16406
+ name: "select_text_shape",
16407
+ title: "Select the shape of text",
16408
+ description: "Horizontal / Vertical Type Mask: selects the shape of text laid out as add_text_layer would place it (font family, size, weight, tracking, box, rotation; colour is ignored), combined with the current selection by mode. No layer is added. Vertical stacks the characters top to bottom, centred on x (one column; add more with mode add). Use it to cut text out of an image (layer_via_copy), fill letters with a photo (add_layer_mask), or mask an adjustment. Check fonts with list_fonts first. One undo step. Returns the selection.",
16409
+ inputSchema: {
16410
+ type: "object",
16411
+ properties: {
16412
+ text: { type: "string", minLength: 1, description: "The text; \\n starts a new line (horizontal only)." },
16413
+ x: { type: "number", description: "Document x of the origin, pixels (vertical: the column centre)." },
16414
+ y: { type: "number", description: "Document y of the origin (the first baseline), pixels." },
16415
+ orientation: { type: "string", enum: ["horizontal", "vertical"], description: "horizontal (default) or vertical (characters stacked top to bottom)." },
16416
+ box: {
16417
+ type: "object",
16418
+ description: "Horizontal only: wrap in this box, pixels.",
16419
+ properties: { width: { type: "number", minimum: 1, description: "Box width, pixels." }, height: { type: "number", minimum: 1, description: "Box height, pixels." } },
16420
+ required: ["width", "height"],
16421
+ additionalProperties: false
16422
+ },
16423
+ style: { type: "object", description: "Character style (size in px, family, weight, tracking\u2026; colour is ignored).", properties: STYLE, additionalProperties: false },
16424
+ paragraph: { type: "object", description: "Paragraph style (alignment, leading\u2026).", properties: PARAGRAPH, additionalProperties: false },
16425
+ rotation: { type: "number", description: "Degrees clockwise about the origin (default 0)." },
16426
+ mode: { type: "string", enum: ["replace", "add", "subtract", "intersect"], description: "How it combines with the current selection (default replace)." },
16427
+ feather: { type: "number", minimum: 0, maximum: 1e3, description: "Feather radius in px (default 0)." },
16428
+ antiAlias: { type: "boolean", description: "Soft glyph edges (default true)." }
16429
+ },
16430
+ required: ["text", "x", "y"],
16431
+ additionalProperties: false
16432
+ },
16433
+ annotations: { title: "Select the shape of text", ...WRITE9 },
16434
+ handler: (args) => {
16435
+ const init = pick2(args, ["text", "x", "y", "orientation", "box", "style", "paragraph", "rotation", "mode", "feather", "antiAlias"]);
16436
+ controller.selectTextShape(init);
16437
+ const sel = controller.getSelection();
16438
+ return { empty: !sel, bounds: sel?.bounds ?? null, area: Math.round(sel ? selectionArea(sel) : 0) };
16439
+ }
16440
+ };
16441
+ return [listFonts, get, add2, setText, setStyle, setParagraph, setFrame, toShape, typeMask];
16184
16442
  }
16185
16443
 
16186
16444
  // src/image-editor/tools.ts
@@ -16782,6 +17040,7 @@ function layerMenu(controller) {
16782
17040
  { action: "toggle-mask-link", label: active?.mask?.linked === false ? "Link Layer Mask" : "Unlink Layer Mask", enabled: Boolean(active?.mask && !active.mask.source) },
16783
17041
  { action: "add-vector-mask", label: "Add Vector Mask (Reveal All)", enabled: Boolean(active && active.type !== "shape" && !active.mask?.source) },
16784
17042
  { action: "add-vector-mask-hide", label: "Add Vector Mask (Hide All)", enabled: Boolean(active && active.type !== "shape" && !active.mask?.source) },
17043
+ { action: "add-vector-mask-selection", label: "Add Vector Mask from Selection", enabled: Boolean(active && active.type !== "shape" && !active.mask?.source) && hasSel },
16785
17044
  { action: "delete-vector-mask", label: "Delete Vector Mask", enabled: Boolean(active?.vectorMask) },
16786
17045
  { action: "toggle-vector-mask", label: active?.vectorMask?.enabled === false ? "Enable Vector Mask" : "Disable Vector Mask", enabled: Boolean(active?.vectorMask) },
16787
17046
  { action: "rasterize-vector-mask", label: "Rasterize Vector Mask", enabled: Boolean(active?.vectorMask) },
@@ -16860,6 +17119,9 @@ async function runLayerMenuAction(controller, action) {
16860
17119
  case "add-vector-mask-hide":
16861
17120
  if (id) controller.addVectorMask(id, action === "add-vector-mask" ? "reveal-all" : "hide-all");
16862
17121
  break;
17122
+ case "add-vector-mask-selection":
17123
+ if (id) controller.addVectorMask(id, "selection");
17124
+ break;
16863
17125
  case "delete-vector-mask":
16864
17126
  if (id) controller.removeVectorMask(id);
16865
17127
  break;
@@ -18850,6 +19112,7 @@ export {
18850
19112
  patchTextStyle,
18851
19113
  pathStrokeCoverage,
18852
19114
  pathStrokeDabs,
19115
+ pathToSelection,
18853
19116
  perDabStrength,
18854
19117
  photoFilterMatrix,
18855
19118
  pigment8,
@@ -18978,6 +19241,7 @@ export {
18978
19241
  selectionRing,
18979
19242
  selectionToCoverage,
18980
19243
  selectionToPixels,
19244
+ selectionToSubpaths,
18981
19245
  selectionValueAt,
18982
19246
  serializeChannels,
18983
19247
  serializeColorMode,
@@ -19035,6 +19299,7 @@ export {
19035
19299
  stepSlotTs,
19036
19300
  strokeGrainCatch,
19037
19301
  styleAt,
19302
+ subpathsToSvgPath,
19038
19303
  sumHistograms,
19039
19304
  surfaceBytes,
19040
19305
  surfaceDepth,
@@ -67,7 +67,7 @@ export type DropZone = 'above' | 'below' | 'inside';
67
67
  export declare function dropZone(fraction: number, isGroup: boolean): DropZone;
68
68
  /** The placement for `move_layer` when `dragId` is dropped on row `targetId` in `zone`, or `null` for a no-op. */
69
69
  export declare function dropPlacement(layers: readonly ImageLayer[], dragId: string, targetId: string, zone: DropZone): LayerPlacement | null;
70
- export type LayerMenuAction = 'new-layer' | 'new-group' | 'duplicate' | 'delete' | 'group-selected' | 'ungroup' | 'merge-down' | 'merge-visible' | 'flatten' | 'via-copy' | 'via-cut' | 'add-mask' | 'add-vector-mask' | 'add-vector-mask-hide' | 'delete-vector-mask' | 'toggle-vector-mask' | 'rasterize-vector-mask' | 'add-mask-selection' | 'add-mask-hide-selection' | 'add-mask-transparency' | 'select-transparency' | 'apply-mask' | 'delete-mask' | 'toggle-mask' | 'toggle-mask-link' | 'toggle-clip' | 'rasterize' | 'convert-smart' | 'edit-contents' | 'smart-via-copy' | 'add-filter-mask';
70
+ export type LayerMenuAction = 'new-layer' | 'new-group' | 'duplicate' | 'delete' | 'group-selected' | 'ungroup' | 'merge-down' | 'merge-visible' | 'flatten' | 'via-copy' | 'via-cut' | 'add-mask' | 'add-vector-mask' | 'add-vector-mask-hide' | 'add-vector-mask-selection' | 'delete-vector-mask' | 'toggle-vector-mask' | 'rasterize-vector-mask' | 'add-mask-selection' | 'add-mask-hide-selection' | 'add-mask-transparency' | 'select-transparency' | 'apply-mask' | 'delete-mask' | 'toggle-mask' | 'toggle-mask-link' | 'toggle-clip' | 'rasterize' | 'convert-smart' | 'edit-contents' | 'smart-via-copy' | 'add-filter-mask';
71
71
  export interface LayerMenuItem {
72
72
  action: LayerMenuAction;
73
73
  label: string;
@@ -67,7 +67,7 @@ export type DropZone = 'above' | 'below' | 'inside';
67
67
  export declare function dropZone(fraction: number, isGroup: boolean): DropZone;
68
68
  /** The placement for `move_layer` when `dragId` is dropped on row `targetId` in `zone`, or `null` for a no-op. */
69
69
  export declare function dropPlacement(layers: readonly ImageLayer[], dragId: string, targetId: string, zone: DropZone): LayerPlacement | null;
70
- export type LayerMenuAction = 'new-layer' | 'new-group' | 'duplicate' | 'delete' | 'group-selected' | 'ungroup' | 'merge-down' | 'merge-visible' | 'flatten' | 'via-copy' | 'via-cut' | 'add-mask' | 'add-vector-mask' | 'add-vector-mask-hide' | 'delete-vector-mask' | 'toggle-vector-mask' | 'rasterize-vector-mask' | 'add-mask-selection' | 'add-mask-hide-selection' | 'add-mask-transparency' | 'select-transparency' | 'apply-mask' | 'delete-mask' | 'toggle-mask' | 'toggle-mask-link' | 'toggle-clip' | 'rasterize' | 'convert-smart' | 'edit-contents' | 'smart-via-copy' | 'add-filter-mask';
70
+ export type LayerMenuAction = 'new-layer' | 'new-group' | 'duplicate' | 'delete' | 'group-selected' | 'ungroup' | 'merge-down' | 'merge-visible' | 'flatten' | 'via-copy' | 'via-cut' | 'add-mask' | 'add-vector-mask' | 'add-vector-mask-hide' | 'add-vector-mask-selection' | 'delete-vector-mask' | 'toggle-vector-mask' | 'rasterize-vector-mask' | 'add-mask-selection' | 'add-mask-hide-selection' | 'add-mask-transparency' | 'select-transparency' | 'apply-mask' | 'delete-mask' | 'toggle-mask' | 'toggle-mask-link' | 'toggle-clip' | 'rasterize' | 'convert-smart' | 'edit-contents' | 'smart-via-copy' | 'add-filter-mask';
71
71
  export interface LayerMenuItem {
72
72
  action: LayerMenuAction;
73
73
  label: string;
@@ -38,8 +38,8 @@ export declare function transformVectorMask(vm: VectorMask, f: (p: {
38
38
  }, affine?: AffineMatrix): VectorMask;
39
39
  /** A linked vector mask moved with its layer by (dx, dy); unlinked ones stay. */
40
40
  export declare function followLayerMove<T extends ImageLayer>(layer: T, dx: number, dy: number): T;
41
- /** Bounds of a vector mask's paths (document pixels), or `null` when it has none. */
42
- export declare function vectorMaskBounds(vm: VectorMask): CropBox | null;
41
+ /** Bounds of the area a vector mask's paths enclose (document pixels, the curves themselves, not their handles), or `null` when it has none. */
42
+ export declare function vectorMaskBounds(vm: Pick<VectorMask, 'components'>): CropBox | null;
43
43
  /**
44
44
  * A vector mask's thumbnail over the `width × height` canvas, at most `maxSide` px: grey RGBA,
45
45
  * white where the path reveals (cached per mask object).
@@ -38,8 +38,8 @@ export declare function transformVectorMask(vm: VectorMask, f: (p: {
38
38
  }, affine?: AffineMatrix): VectorMask;
39
39
  /** A linked vector mask moved with its layer by (dx, dy); unlinked ones stay. */
40
40
  export declare function followLayerMove<T extends ImageLayer>(layer: T, dx: number, dy: number): T;
41
- /** Bounds of a vector mask's paths (document pixels), or `null` when it has none. */
42
- export declare function vectorMaskBounds(vm: VectorMask): CropBox | null;
41
+ /** Bounds of the area a vector mask's paths enclose (document pixels, the curves themselves, not their handles), or `null` when it has none. */
42
+ export declare function vectorMaskBounds(vm: Pick<VectorMask, 'components'>): CropBox | null;
43
43
  /**
44
44
  * A vector mask's thumbnail over the `width × height` canvas, at most `maxSide` px: grey RGBA,
45
45
  * white where the path reveals (cached per mask object).
@@ -11,3 +11,4 @@ export * from './segmentation.cjs';
11
11
  export * from './stroke.cjs';
12
12
  export * from './channels.cjs';
13
13
  export * from './mask-files.cjs';
14
+ export * from './path.cjs';
@@ -11,3 +11,4 @@ export * from './segmentation.js';
11
11
  export * from './stroke.js';
12
12
  export * from './channels.js';
13
13
  export * from './mask-files.js';
14
+ export * from './path.js';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Selections ⇄ paths (Photoshop's Make Work Path and Make Selection): a selection's outline traced
3
+ * into Bézier subpaths, and a path filled into a selection.
4
+ */
5
+ import type { VectorSubpath } from '../../vector/types.cjs';
6
+ import type { ShapeComponent, ShapeFillRule } from '../layers/model.cjs';
7
+ import { type SelectionMask } from './mask.cjs';
8
+ export interface SelectionToPathOptions {
9
+ /** How far (px) the path may stray from the outline (default 0.5; Photoshop's Make Work Path tolerance). */
10
+ tolerance?: number;
11
+ /** 0: straight sides and sharp corners; 1: as smooth as the outline allows (default 0.5). */
12
+ cornerRoundness?: number;
13
+ /** Islands and holes smaller than this many square pixels are left out (default 4). */
14
+ minArea?: number;
15
+ /** Coverage (0..255) where the outline runs (default 128, half selected). */
16
+ threshold?: number;
17
+ }
18
+ /**
19
+ * The outline of a selection as closed subpaths in document pixels (holes included, so fill them
20
+ * even-odd). Traced in the selection's bounds only; an empty selection gives none.
21
+ */
22
+ export declare function selectionToSubpaths(mask: SelectionMask, options?: SelectionToPathOptions): VectorSubpath[];
23
+ /** Subpaths as compact SVG path data (absolute, `precision` decimals). */
24
+ export declare function subpathsToSvgPath(subpaths: readonly VectorSubpath[], precision?: number): string;
25
+ export interface PathToSelectionOptions {
26
+ /** Feather radius in px applied after filling (default 0). */
27
+ feather?: number;
28
+ /** Soft edges (default true); false makes every pixel fully in or out. */
29
+ antiAlias?: boolean;
30
+ /** How overlapping subpaths fill (default nonzero). */
31
+ fillRule?: ShapeFillRule;
32
+ }
33
+ /**
34
+ * Make Selection: the area of `components` (document pixels, combined by their ops) over a
35
+ * `width × height` image, computed in the path's bounds only.
36
+ */
37
+ export declare function pathToSelection(components: readonly ShapeComponent[], width: number, height: number, options?: PathToSelectionOptions): SelectionMask;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Selections ⇄ paths (Photoshop's Make Work Path and Make Selection): a selection's outline traced
3
+ * into Bézier subpaths, and a path filled into a selection.
4
+ */
5
+ import type { VectorSubpath } from '../../vector/types.js';
6
+ import type { ShapeComponent, ShapeFillRule } from '../layers/model.js';
7
+ import { type SelectionMask } from './mask.js';
8
+ export interface SelectionToPathOptions {
9
+ /** How far (px) the path may stray from the outline (default 0.5; Photoshop's Make Work Path tolerance). */
10
+ tolerance?: number;
11
+ /** 0: straight sides and sharp corners; 1: as smooth as the outline allows (default 0.5). */
12
+ cornerRoundness?: number;
13
+ /** Islands and holes smaller than this many square pixels are left out (default 4). */
14
+ minArea?: number;
15
+ /** Coverage (0..255) where the outline runs (default 128, half selected). */
16
+ threshold?: number;
17
+ }
18
+ /**
19
+ * The outline of a selection as closed subpaths in document pixels (holes included, so fill them
20
+ * even-odd). Traced in the selection's bounds only; an empty selection gives none.
21
+ */
22
+ export declare function selectionToSubpaths(mask: SelectionMask, options?: SelectionToPathOptions): VectorSubpath[];
23
+ /** Subpaths as compact SVG path data (absolute, `precision` decimals). */
24
+ export declare function subpathsToSvgPath(subpaths: readonly VectorSubpath[], precision?: number): string;
25
+ export interface PathToSelectionOptions {
26
+ /** Feather radius in px applied after filling (default 0). */
27
+ feather?: number;
28
+ /** Soft edges (default true); false makes every pixel fully in or out. */
29
+ antiAlias?: boolean;
30
+ /** How overlapping subpaths fill (default nonzero). */
31
+ fillRule?: ShapeFillRule;
32
+ }
33
+ /**
34
+ * Make Selection: the area of `components` (document pixels, combined by their ops) over a
35
+ * `width × height` image, computed in the path's bounds only.
36
+ */
37
+ export declare function pathToSelection(components: readonly ShapeComponent[], width: number, height: number, options?: PathToSelectionOptions): SelectionMask;
@@ -2,7 +2,7 @@ import { type SelectionMask, type SelectionMode } from './mask.cjs';
2
2
  import type { ColorDistance, ColorRangePreset } from './wand.cjs';
3
3
  import type { RefineEdgeParams } from './refine.cjs';
4
4
  /** Selection tools of the image editor UI. */
5
- export type SelectionToolKind = 'wand' | 'rect' | 'ellipse' | 'row' | 'column' | 'lasso' | 'polygon' | 'range';
5
+ export type SelectionToolKind = 'wand' | 'rect' | 'ellipse' | 'row' | 'column' | 'lasso' | 'polygon' | 'range' | 'type' | 'type-vertical';
6
6
  export declare const SELECTION_TOOLS: readonly {
7
7
  id: SelectionToolKind;
8
8
  label: string;
@@ -31,6 +31,12 @@ export interface SelectionToolOptions {
31
31
  localized?: number;
32
32
  /** Color Range: select everything except the sampled colours. */
33
33
  rangeInvert?: boolean;
34
+ /** Type Mask: the text to select (default "Type"). A click places its first baseline. */
35
+ typeText?: string;
36
+ /** Type Mask: font size in px (default 72). */
37
+ typeSize?: number;
38
+ /** Type Mask: font family (default sans-serif). */
39
+ typeFamily?: string;
34
40
  }
35
41
  export declare const DEFAULT_SELECTION_TOOL_OPTIONS: SelectionToolOptions;
36
42
  /**