editmamei 1.7.1 → 1.9.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.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +1790 -26
  3. package/dist/backends/gimp/bridge/ops.py +3013 -168
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +7 -0
  8. package/dist/core/server.js +58 -13
  9. package/dist/core/settings.js +7 -0
  10. package/dist/core/tool-activity.js +15 -1
  11. package/dist/core/tool-groups.js +9 -1
  12. package/dist/core/tool-tiers.js +9 -1
  13. package/dist/delivery/client.js +3 -0
  14. package/dist/modules/gimp/index.js +12 -2
  15. package/dist/skills/editmamei-skill.zip +0 -0
  16. package/dist/telemetry/sanitize.js +15 -1
  17. package/dist/tools/adjustment-tools.js +9 -1
  18. package/dist/tools/document-tools.js +118 -21
  19. package/dist/tools/filter-tools.js +2 -1
  20. package/dist/tools/gimp-adjustment-tools.js +7 -6
  21. package/dist/tools/gimp-checkpoint-tools.js +7 -1
  22. package/dist/tools/gimp-core-tools.js +10 -11
  23. package/dist/tools/gimp-document-tools.js +100 -18
  24. package/dist/tools/gimp-effect-tools.js +56 -10
  25. package/dist/tools/gimp-fill-tools.js +256 -0
  26. package/dist/tools/gimp-filter-tools.js +4 -1
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +48 -10
  29. package/dist/tools/gimp-layer-tools.js +7 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-result-text.js +104 -0
  32. package/dist/tools/gimp-retouch-tools.js +238 -0
  33. package/dist/tools/gimp-selection-tools.js +374 -0
  34. package/dist/tools/gimp-shared.js +12 -0
  35. package/dist/tools/gimp-text-tools.js +241 -0
  36. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  37. package/dist/tools/gimp-verify-tools.js +13 -15
  38. package/dist/tools/image-placement-tools.js +6 -1
  39. package/dist/tools/sky-tools.js +2 -1
  40. package/dist/utils/gimp-path.js +18 -22
  41. package/dist/utils/local-path.js +99 -0
  42. package/dist/utils/operation-timeouts.js +12 -2
  43. package/dist/version.js +1 -1
  44. package/package.json +5 -5
  45. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -13,6 +13,7 @@
13
13
  # helpers below do real file I/O, same as any other stdlib code would.
14
14
 
15
15
  import collections
16
+ import itertools
16
17
  import json
17
18
  import math
18
19
  import os
@@ -21,7 +22,13 @@ import sys
21
22
  import time
22
23
  import traceback
23
24
 
24
- LEDGER_VERSION = 1
25
+ # The newest ledger version this bridge reads and writes. A document is written at the lowest
26
+ # version that can hold its records: version 2 added the outline, outer_glow and surface_blur
27
+ # types, whose params an earlier bridge cannot read. Written as 2, an earlier bridge treats the
28
+ # ledger as newer than itself (it reads the filters back from GIMP and leaves the ledger alone)
29
+ # instead of failing on them; a ledger without them stays version 1, which every bridge reads.
30
+ LEDGER_VERSION = 2
31
+ LEDGER_V2_TYPES = frozenset(('outline', 'outer_glow', 'surface_blur'))
25
32
  LEDGER_WRITER = 'editmamei'
26
33
 
27
34
  HIST_CHANNELS = ('luminance', 'red', 'green', 'blue')
@@ -84,7 +91,9 @@ SPATIAL_SCALE_PROPS = {
84
91
  'gegl:gaussian-blur': ('std-dev-x', 'std-dev-y'),
85
92
  'gegl:motion-blur-linear': ('length',),
86
93
  'gegl:focus-blur': ('blur-radius',),
87
- 'gegl:dropshadow': ('x', 'y', 'radius'),
94
+ 'gegl:dropshadow': ('x', 'y', 'radius', 'grow-radius'),
95
+ # surface_blur's blur-radius is a pixel neighborhood, scaled like any other radius.
96
+ 'gegl:bilateral-filter': ('blur-radius',),
88
97
  # gegl:vignette and gegl:mono-mixer are deliberately absent: vignette's radius/x/y are
89
98
  # proportional (fractions of the LAYER's own size, not absolute pixel lengths), and
90
99
  # mono-mixer is a per-pixel channel-weight filter with no spatial extent at all.
@@ -128,6 +137,7 @@ MASK_TYPES = ('rectangle', 'ellipse', 'gradient_linear', 'gradient_radial')
128
137
  # hard_light=hardlight darken=darken-only lighten=lighten-only difference=difference
129
138
  # exclusion=exclusion addition=addition subtract=subtract divide=divide dodge=dodge burn=burn
130
139
  # hue=hsv-hue saturation=hsv-saturation color=hsl-color luminosity=luminance
140
+ # grain_extract=grain-extract grain_merge=grain-merge
131
141
  # hue/saturation/color/luminosity map to the HSV/HSL (not LCH) variants -- those are the ones
132
142
  # GIMP's own UI labels plainly "Hue"/"Saturation"/"Color"/"Luminosity" without a "(LCH)" suffix.
133
143
  LAYER_MODES = {
@@ -150,6 +160,8 @@ LAYER_MODES = {
150
160
  'saturation': 'HSV_SATURATION',
151
161
  'color': 'HSL_COLOR',
152
162
  'luminosity': 'LUMINANCE',
163
+ 'grain_extract': 'GRAIN_EXTRACT',
164
+ 'grain_merge': 'GRAIN_MERGE',
153
165
  }
154
166
 
155
167
 
@@ -159,6 +171,171 @@ def validate_layer_mode(value):
159
171
  return value
160
172
 
161
173
 
174
+ # ---- gimp_fill ---------------------------------------------------------------------------------
175
+
176
+ FILL_OPS = ('solid', 'linear_gradient', 'radial_gradient')
177
+ FILL_MIN_STOPS = 2
178
+ FILL_MAX_STOPS = 8
179
+ # gimp_fill takes gimp_layer's blend-mode names plus 'lch_color': GIMP's LCh Color keeps the
180
+ # layer underneath's lightness exactly and swaps only hue and chroma, which is what a recolor
181
+ # wants. 'color' stays HSL Color (see LAYER_MODES), so existing callers are unchanged.
182
+ FILL_MODES = dict(LAYER_MODES, lch_color='LCH_COLOR')
183
+ _HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
184
+
185
+
186
+ def _is_number(v):
187
+ if not isinstance(v, (int, float)) or isinstance(v, bool):
188
+ return False
189
+ try:
190
+ return math.isfinite(v)
191
+ except OverflowError: # an int too large for a float (10**400)
192
+ return False
193
+
194
+
195
+ def parse_hex_color(name, value):
196
+ """'#rrggbb' (strictly: a leading '#' and six hex digits) -> (r, g, b) ints 0..255."""
197
+ if not isinstance(value, str) or not _HEX_COLOR_RE.match(value):
198
+ raise ValueError('%s must be a color like #rrggbb' % name)
199
+ return int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16)
200
+
201
+
202
+ def _fill_point(name, value, default):
203
+ if value is None:
204
+ return default
205
+ if not isinstance(value, dict) or not _is_number(value.get('x')) or not _is_number(value.get('y')):
206
+ raise ValueError('%s must be {x, y} with finite numbers' % name)
207
+ try:
208
+ x, y = float(value['x']), float(value['y'])
209
+ except OverflowError: # a Python int too large for a float (10**400)
210
+ raise ValueError('%s is out of range' % name)
211
+ if abs(x) > FILL_MAX_COORD_PX or abs(y) > FILL_MAX_COORD_PX:
212
+ raise ValueError('%s must be within +/-%d px' % (name, FILL_MAX_COORD_PX))
213
+ return x, y
214
+
215
+
216
+ def validate_fill_stops(stops):
217
+ """2..8 stops [{offset 0..1, color #rrggbb, opacity? 0..100}], offsets non-decreasing (they
218
+ must arrive sorted: a mis-ordered list is a caller mistake, not something to silently fix).
219
+ Returns [(offset, (r, g, b), alpha 0..1)]."""
220
+ if not isinstance(stops, list) or not FILL_MIN_STOPS <= len(stops) <= FILL_MAX_STOPS:
221
+ raise ValueError('stops must be a list of %d..%d stops' % (FILL_MIN_STOPS, FILL_MAX_STOPS))
222
+ out = []
223
+ for i, s in enumerate(stops):
224
+ if not isinstance(s, dict):
225
+ raise ValueError('stops[%d] must be an object {offset, color, opacity?}' % i)
226
+ offset = s.get('offset')
227
+ if not _is_number(offset) or not 0 <= offset <= 1:
228
+ raise ValueError('stops[%d].offset must be a number within 0..1' % i)
229
+ opacity = s.get('opacity', 100)
230
+ if not _is_number(opacity) or not 0 <= opacity <= 100:
231
+ raise ValueError('stops[%d].opacity must be within 0..100' % i)
232
+ if out and offset < out[-1][0]:
233
+ raise ValueError('stops must be sorted by offset: stops[%d] comes before stops[%d]' % (i, i - 1))
234
+ out.append((float(offset), parse_hex_color('stops[%d].color' % i, s.get('color')), opacity / 100.0))
235
+ return out
236
+
237
+
238
+ def gradient_ramp(stops):
239
+ """The stops widened to cover the whole 0..1 range (the first color holds back to 0, the last
240
+ holds on to 1), so the gradient is defined everywhere. Returns a new list."""
241
+ ramp = list(stops)
242
+ if ramp[0][0] > 0:
243
+ ramp.insert(0, (0.0,) + ramp[0][1:])
244
+ if ramp[-1][0] < 1:
245
+ ramp.append((1.0,) + ramp[-1][1:])
246
+ return ramp
247
+
248
+
249
+ def _fill_name(value, op):
250
+ name = str(value or ('Fill' if op == 'solid' else 'Gradient')).strip() or 'Fill'
251
+ if len(name) > FILL_MAX_STRING_LEN:
252
+ raise ValueError('name must be at most %d characters' % FILL_MAX_STRING_LEN)
253
+ return name
254
+
255
+
256
+ def validate_fill_args(args, width, height):
257
+ """Everything gimp_fill needs, validated before any GIMP call. Points are image pixels."""
258
+ op = validate_choice('op', args.get('op'), FILL_OPS)
259
+ opts = {
260
+ 'op': op,
261
+ 'name': _fill_name(args.get('name'), op),
262
+ 'mode': validate_choice('mode', args.get('mode', 'normal'), FILL_MODES),
263
+ 'mask': args.get('mask'),
264
+ }
265
+ opacity = args.get('opacity', 100)
266
+ if not _is_number(opacity) or not 0 <= opacity <= 100:
267
+ raise ValueError('opacity must be within 0..100')
268
+ opts['opacity'] = float(opacity)
269
+ if opts['mask'] is not None and (not isinstance(opts['mask'], str) or not opts['mask']):
270
+ raise ValueError('mask must be a channel name')
271
+ if opts['mask'] is not None and len(opts['mask']) > FILL_MAX_STRING_LEN:
272
+ raise ValueError('mask must be at most %d characters' % FILL_MAX_STRING_LEN)
273
+ if op == 'solid':
274
+ opts['color'] = parse_hex_color('color', args.get('color'))
275
+ for k in ('stops', 'start', 'end', 'center', 'radius'):
276
+ if args.get(k) is not None:
277
+ raise ValueError('%s does not apply to op solid' % k)
278
+ return opts
279
+ if args.get('color') is not None:
280
+ raise ValueError('color applies to op solid only; a gradient takes stops')
281
+ opts['stops'] = validate_fill_stops(args.get('stops'))
282
+ if op == 'linear_gradient':
283
+ for k in ('center', 'radius'):
284
+ if args.get(k) is not None:
285
+ raise ValueError('%s does not apply to op linear_gradient' % k)
286
+ opts['start'] = _fill_point('start', args.get('start'), (width / 2.0, 0.0))
287
+ opts['end'] = _fill_point('end', args.get('end'), (width / 2.0, float(height)))
288
+ if math.hypot(opts['end'][0] - opts['start'][0], opts['end'][1] - opts['start'][1]) < 1:
289
+ raise ValueError('start and end must be at least 1 pixel apart')
290
+ else:
291
+ for k in ('start', 'end'):
292
+ if args.get(k) is not None:
293
+ raise ValueError('%s does not apply to op radial_gradient' % k)
294
+ opts['center'] = _fill_point('center', args.get('center'), (width / 2.0, height / 2.0))
295
+ radius = args.get('radius')
296
+ if radius is None:
297
+ radius = math.hypot(width, height) / 2.0
298
+ if not _is_number(radius) or not 1 <= radius <= FILL_MAX_COORD_PX:
299
+ raise ValueError('radius must be within 1..%d pixels' % FILL_MAX_COORD_PX)
300
+ opts['radius'] = float(radius)
301
+ return opts
302
+
303
+
304
+ # ---- masked measurement (gimp_get_histogram `mask`) ---------------------------------------------
305
+
306
+ MASK_SELECTED_MIN = 128 # a mask pixel counts as selected at 50% or more (>= 128 of 255)
307
+ _MASK_FLAG_TABLE = bytes(1 if v >= MASK_SELECTED_MIN else 0 for v in range(256))
308
+
309
+
310
+ def mask_flags(mask_data):
311
+ """One byte per mask pixel: 1 where the pixel is selected (>= 50%), else 0."""
312
+ return bytes(mask_data).translate(_MASK_FLAG_TABLE)
313
+
314
+
315
+ # A hard-edged mask scaled down keeps a ring of partial-coverage pixels around its edge; a thin
316
+ # one is ALL ring (a 1 px line reads as a pale smear whose pixels blend with their neighbors).
317
+ # Past this share of edge pixels among the touched ones the scaled copy is not trusted.
318
+ MASK_EDGE_FRACTION_MAX = 0.25
319
+
320
+
321
+ def mask_is_proxy_reliable(mask_data):
322
+ """Whether a downscaled copy's mask is solid enough to measure. Not when it touches nothing
323
+ (it may have vanished in the scale-down; the full-resolution pass tells that from a mask that
324
+ is truly empty) or when its partial-coverage pixels (not 0, not 255) are more than
325
+ MASK_EDGE_FRACTION_MAX of all the pixels it touches."""
326
+ data = bytes(mask_data)
327
+ touched = len(data) - data.count(0)
328
+ if touched == 0:
329
+ return False
330
+ edge = touched - data.count(255)
331
+ return edge <= MASK_EDGE_FRACTION_MAX * touched
332
+
333
+
334
+ def select_masked(data, flags):
335
+ """The bytes of `data` (one value per pixel) at the positions `flags` marks 1."""
336
+ return bytes(itertools.compress(data, flags))
337
+
338
+
162
339
  # gimp_layer op=create's fill options -- verified live that `Gimp.FillType.TRANSPARENT` exists
163
340
  # and that a freshly created layer's content is otherwise undefined, so `create` always fills
164
341
  # explicitly rather than trusting whatever `Gimp.Layer.new` leaves behind.
@@ -175,16 +352,170 @@ IMAGE_MODES = ('rgb', 'grayscale')
175
352
  EXPORT_FORMATS = {'.jpg': 'jpeg', '.jpeg': 'jpeg', '.png': 'png', '.webp': 'webp',
176
353
  '.tif': 'tiff', '.tiff': 'tiff'}
177
354
 
355
+ # Formats `_export_layered` (ops.py) writes with the layer stack kept, instead of a flattened copy.
356
+ LAYERED_EXPORT_FORMATS = {'.psd': 'psd'}
357
+
178
358
  BIT_DEPTHS = (8, 16)
179
359
 
180
360
  # A DoS floor for `resize`: bounds a single call's memory/time cost regardless of how large the
181
361
  # SOURCE document already is (crop/rotate/flip only ever shrink-or-preserve the existing canvas,
182
362
  # so they don't need this; resize is the one op that can grow it arbitrarily from a tiny source).
183
363
  MAX_RESIZE_SIDE_PX = 30_000
184
- MAX_RESIZE_MEGAPIXELS = 250
364
+ # gimp_fill's geometry bounds: a gradient axis may sit well off-canvas but not at 1e308, where
365
+ # GIMP's float arithmetic overflows. Also the longest layer name / mask channel name it takes.
366
+ FILL_MAX_COORD_PX = 10 * MAX_RESIZE_SIDE_PX
367
+ FILL_MAX_STRING_LEN = 255
368
+ _DEFAULT_MAX_RESIZE_MEGAPIXELS = 250
369
+
370
+
371
+ def megapixel_cap_from_env(raw, default=_DEFAULT_MAX_RESIZE_MEGAPIXELS):
372
+ """A host-supplied (`EM_GIMP_MAX_MEGAPIXELS`) ceiling that can only LOWER `default` -- a memory-
373
+ constrained host (e.g. a small container) tightens every pixel-growing op's cap this way. Anything
374
+ that is not a finite positive number below `default` (unset, empty, junk, NaN/inf, zero/negative,
375
+ or an attempt to RAISE it) is ignored and `default` is returned unchanged."""
376
+ if not raw:
377
+ return default
378
+ try:
379
+ value = float(raw)
380
+ except (TypeError, ValueError):
381
+ return default
382
+ if not math.isfinite(value) or value <= 0 or value >= default:
383
+ return default
384
+ return value
385
+
386
+
387
+ MAX_RESIZE_MEGAPIXELS = megapixel_cap_from_env(os.environ.get('EM_GIMP_MAX_MEGAPIXELS'))
185
388
 
186
389
  MAX_FEATHER_PX = 1000
187
390
 
391
+ # Hardening a mask read as "Y' u8": 50% and above becomes fully selected (255), the rest 0. The
392
+ # same 128 cut `_channel_coverage` counts as selected. Applied with `bytes.translate`, a C pass.
393
+ HARDEN_TABLE = bytes(255 if v >= 128 else 0 for v in range(256))
394
+ # Rows per band when hardening, so a very large mask is never held twice in memory at once.
395
+ HARDEN_BAND_ROWS = 512
396
+
397
+
398
+ def harden_mask_bytes(data):
399
+ """`data` ("Y' u8" mask bytes) with every value hardened to 0 or 255 at the 50% cut."""
400
+ return bytes(data).translate(HARDEN_TABLE)
401
+
402
+ # gimp_modify_selection's expand/contract/border radius cap -- much lower than MAX_FEATHER_PX.
403
+ # `Gimp.Selection.grow`/`shrink`/`border` are morphological (structuring-element) operations whose
404
+ # cost scales with the radius, unlike `feather` (a GEGL blur, flat ~0.5s regardless of radius on a
405
+ # 24MP image, measured live). This is the cap for a document at or below MORPHOLOGY_BASELINE_MP;
406
+ # a bigger document gets a SMALLER cap -- see `effective_morphology_px`.
407
+ MAX_MORPHOLOGY_PX = 150
408
+
409
+ # Measured live at px=150 (border, the most expensive of the three): ~30s at 24MP (6016x4000),
410
+ # ~73.5s at 100MP (14000x7143), and a GIMP session OUTRIGHT TIMEOUT past 180s at ~217MP
411
+ # (30000x7228, this bridge's own largest allowed document -- MAX_RESIZE_SIDE_PX/
412
+ # MAX_RESIZE_MEGAPIXELS). Cost does not scale linearly with megapixels (100MP was only ~2.45x the
413
+ # 24MP cost for a ~4.17x bigger image), but it very much does not fit inside
414
+ # gimp_modify_selection's budget either, so the cap itself has to shrink as the document grows.
415
+ MORPHOLOGY_BASELINE_MP = 24.0
416
+ # A document-size floor so a huge document still gets a meaningfully large morphology radius
417
+ # rather than being squeezed to single-digit pixels.
418
+ MIN_MORPHOLOGY_PX = 10
419
+
420
+
421
+ def effective_morphology_px(width, height):
422
+ """The actual expand/contract/border cap for a `width`x`height` document: `MAX_MORPHOLOGY_PX`
423
+ at or below `MORPHOLOGY_BASELINE_MP`, shrinking as 1/megapixels above it (simple and, per the
424
+ live measurements on `MAX_MORPHOLOGY_PX`'s own comment, SAFELY conservative -- the real cost
425
+ curve is sub-linear in megapixels, so this reduces the cap by more than the measured cost
426
+ alone would require). Verified live at the two points that matter: at ~100MP the scaled cap
427
+ (36px) cost ~11.8s; at ~217MP (this bridge's largest allowed document) the scaled cap (17px)
428
+ cost ~27.2s -- both comfortably inside gimp_modify_selection's 45s budget with its own margin,
429
+ even though a large document also carries a substantial FIXED per-call cost that shrinking
430
+ `px` alone cannot remove (measured ~13-17s of that 27.2s at ~217MP comes from px=5 alone,
431
+ i.e. just handling a document that size, before any morphology radius is even considered)."""
432
+ megapixels = (width * height) / 1_000_000.0
433
+ if megapixels <= MORPHOLOGY_BASELINE_MP:
434
+ return MAX_MORPHOLOGY_PX
435
+ scaled = MAX_MORPHOLOGY_PX * MORPHOLOGY_BASELINE_MP / megapixels
436
+ return max(MIN_MORPHOLOGY_PX, min(MAX_MORPHOLOGY_PX, round(scaled)))
437
+
438
+
439
+ # gimp_transform_layer's scale/scale_x_percent/scale_y_percent bound -- generous enough for any
440
+ # real use (100x in either direction) while still keeping a single call's resulting layer size
441
+ # bounded alongside validate_resize_dims' own check on the actual pixel dimensions.
442
+ TRANSFORM_LAYER_MIN_SCALE_PERCENT = 1
443
+ TRANSFORM_LAYER_MAX_SCALE_PERCENT = 10_000
444
+
445
+ # gimp_transform_layer's `interpolation` choices -- the GIMP resampling filters this bridge
446
+ # exposes, by name. The gi-dependent mapping to the real `Gimp.InterpolationType` enum members
447
+ # lives in ops.py (this module stays gi-free).
448
+ TRANSFORM_LAYER_INTERPOLATIONS = ('none', 'linear', 'cubic', 'nohalo', 'lohalo')
449
+
450
+ # gimp_transform_layer's `free` op: offset_x/offset_y bound -- a sanity ceiling on the final
451
+ # absolute translation, well past MAX_RESIZE_SIDE_PX (30000) so any real placement fits, but
452
+ # tight enough that a wildly out-of-range value is refused here rather than only by the derived
453
+ # predicted-origin check (`_validated_move_offset`) it also feeds into.
454
+ TRANSFORM_LAYER_MAX_OFFSET_PX = 100_000
455
+
456
+ # gimp_transform_layer's own op -> the field names that op reads, the same role
457
+ # `type_fields`/`FILTER_COMMON_KEYS` play for gimp_add_adjustment/gimp_add_effect above --
458
+ # `reject_foreign_transform_fields` refuses any OTHER declared field a caller sent (e.g. a
459
+ # PS-style flat `delta_x`, or `skew_h_degrees` on op=scale), so a typo validates instead of
460
+ # silently being ignored by that op's own handler.
461
+ TRANSFORM_LAYER_COMMON_KEYS = frozenset(('image', 'op', 'layer', 'layer_id', 'interpolation'))
462
+
463
+ TRANSFORM_LAYER_OP_FIELDS = {
464
+ 'fit': ('mode',),
465
+ 'scale': ('scale_percent', 'scale_x_percent', 'scale_y_percent'),
466
+ 'move': ('delta', 'absolute', 'center_on'),
467
+ 'rotate': ('degrees',),
468
+ 'flip': ('axis',),
469
+ 'skew': ('skew_h_degrees', 'skew_v_degrees'),
470
+ 'free': ('scale_x_percent', 'scale_y_percent', 'degrees', 'offset_x', 'offset_y'),
471
+ }
472
+
473
+
474
+ def reject_foreign_transform_fields(op, args):
475
+ """Refuse any field `op` does not read -- the gimp_transform_layer analogue of
476
+ `reject_foreign_fields` above, over `TRANSFORM_LAYER_OP_FIELDS` instead of the filter-type
477
+ tables. `op`'s own schema is flat (one property per name across every op), so without this a
478
+ field meant for a DIFFERENT op (`skew_h_degrees` on op=scale, or PS's flat `delta_x` instead
479
+ of this tool's nested `delta`) validates, is ignored by that op's own handler, and silently
480
+ does nothing. None values are ignored (an omitted field)."""
481
+ own = TRANSFORM_LAYER_OP_FIELDS.get(op, ())
482
+ foreign = sorted(
483
+ k for k, v in args.items()
484
+ if v is not None and k not in TRANSFORM_LAYER_COMMON_KEYS and k not in own
485
+ )
486
+ if foreign:
487
+ raise ValueError(
488
+ "op '%s' does not use field(s) %s; its fields are: %s"
489
+ % (op, ', '.join(foreign), ', '.join(own) or '(none)')
490
+ )
491
+
492
+
493
+ def ceil_with_margin(value):
494
+ """`value` rounded UP to the next whole pixel, plus one more -- the conservative integer a
495
+ cap check validates against for a float `transformed_bounds` predicts, matching the extra
496
+ pixel GIMP's own ADJUST transform-resize can add by rounding each edge of a rotated/sheared
497
+ bounding box outward independently (floor the min edge, ceil the max edge), which can land
498
+ the ACTUAL resulting integer width/height one pixel past a plain `ceil` of the float width.
499
+ Never used for the transform's own corner coordinates (those stay exact floats) -- only for
500
+ deciding whether to refuse BEFORE any pixel moves."""
501
+ return int(math.ceil(value)) + 1
502
+
503
+
504
+ def transform_layer_precision_bucket(precision_nick):
505
+ """`Gimp.Precision.value_nick` (e.g. 'u8-non-linear', 'u16-perceptual', 'float-linear')
506
+ bucketed into the '8'/'16'/'32' strings `validate_document_dims`'s own
507
+ `DOCUMENT_MEGAPIXEL_CAP` is keyed by -- gimp_transform_layer validates a layer's predicted
508
+ size against the SAME precision-aware cap gimp_create_document does, rather than always
509
+ assuming 8-bit. u16 and half bucket to '16' (2 bytes/channel); every other precision (u32,
510
+ float, double) buckets to '32' (4+ bytes/channel, the most conservative bucket) -- this
511
+ bridge has no DOCUMENT_MEGAPIXEL_CAP entry finer than that."""
512
+ nick = precision_nick.lower()
513
+ if nick.startswith('u8'):
514
+ return '8'
515
+ if nick.startswith('u16') or nick.startswith('half'):
516
+ return '16'
517
+ return '32'
518
+
188
519
 
189
520
  def require(args, name):
190
521
  """Fetch a required field from an op's `args`, raising ValueError naming it -- the
@@ -195,6 +526,24 @@ def require(args, name):
195
526
  return args[name]
196
527
 
197
528
 
529
+ GINT32_MAX = 2147483647
530
+
531
+
532
+ def gimp_id(value):
533
+ """`value` as an image or item id GIMP's gint32 `get_by_id` can take, or None when it can't be
534
+ one (a bool, not a whole number, below 1, or past gint32). The caller then answers "no ... with
535
+ id N" instead of letting PyGObject raise a raw OverflowError on the lookup."""
536
+ if isinstance(value, bool):
537
+ return None
538
+ try:
539
+ number = int(value)
540
+ except (TypeError, ValueError, OverflowError):
541
+ return None
542
+ if isinstance(value, float) and value != number:
543
+ return None
544
+ return number if 1 <= number <= GINT32_MAX else None
545
+
546
+
198
547
  def require_bool(args, name):
199
548
  """Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
200
549
  is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
@@ -270,7 +619,7 @@ def validate_resize_dims(width, height):
270
619
  raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
271
620
  megapixels = (width * height) / 1_000_000.0
272
621
  if megapixels > MAX_RESIZE_MEGAPIXELS:
273
- raise ValueError('resize target must be at most %d MP' % MAX_RESIZE_MEGAPIXELS)
622
+ raise ValueError('resize target must be at most %g MP' % MAX_RESIZE_MEGAPIXELS)
274
623
  return width, height
275
624
 
276
625
 
@@ -281,7 +630,13 @@ def validate_resize_dims(width, height):
281
630
  # that picks its own bit depth up front (`open`/`resize`/etc. all work on whatever precision an
282
631
  # already-open image happens to be), so it is the one place this DoS floor needs to vary by
283
632
  # precision rather than assuming 8-bit throughout.
284
- DOCUMENT_MEGAPIXEL_CAP = {'8': MAX_RESIZE_MEGAPIXELS, '16': 125, '32': 60}
633
+ # A lowered EM_GIMP_MAX_MEGAPIXELS scales the 16/32-bit caps down with the 8-bit one, never above
634
+ # their own defaults.
635
+ DOCUMENT_MEGAPIXEL_CAP = {
636
+ '8': MAX_RESIZE_MEGAPIXELS,
637
+ '16': min(125, MAX_RESIZE_MEGAPIXELS / 2),
638
+ '32': min(60, MAX_RESIZE_MEGAPIXELS / 4),
639
+ }
285
640
 
286
641
 
287
642
  def validate_document_dims(width, height, precision='8'):
@@ -295,14 +650,182 @@ def validate_document_dims(width, height, precision='8'):
295
650
  cap = DOCUMENT_MEGAPIXEL_CAP[precision]
296
651
  megapixels = (width * height) / 1_000_000.0
297
652
  if megapixels > cap:
298
- raise ValueError('a %s-bit document must be at most %d MP' % (precision, cap))
653
+ raise ValueError(
654
+ '%s %s-bit document must be at most %g MP'
655
+ % ('an' if precision == '8' else 'a', precision, cap)
656
+ )
299
657
  return width, height
300
658
 
301
659
 
660
+ def validate_group_total_pixels(sizes, precision='8'):
661
+ """A GROUP transform allocates every non-group descendant's own result buffer, so the SUM of
662
+ their predicted pixel counts (`sizes`: (width, height) pairs) must fit the same
663
+ precision-aware megapixel ceiling `validate_document_dims` applies to a single layer."""
664
+ cap = DOCUMENT_MEGAPIXEL_CAP[precision]
665
+ megapixels = sum(w * h for w, h in sizes) / 1_000_000.0
666
+ if megapixels > cap:
667
+ raise ValueError(
668
+ "the group's layers would total %.1f MP after this transform; the %s-bit limit is %d MP"
669
+ % (megapixels, precision, cap)
670
+ )
671
+
672
+
302
673
  def validate_feather_px(value):
303
674
  return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
304
675
 
305
676
 
677
+ # ---- match_layer: statistics transfer as per-channel curves ---------------------------------
678
+ #
679
+ # Everything here is pure (no gi). Means and standard deviations are in GIMP's own histogram
680
+ # units (0-255 in the image's perceptual encoding, the same space a gimp:curves filter works in).
681
+
682
+ MATCH_TAG = 'match_layer'
683
+ MATCH_MODES = ('both', 'color', 'tone')
684
+ MATCH_REFERENCES = ('surround', 'below')
685
+ MATCH_DEFAULT_STRENGTH = 70.0
686
+ MATCH_MIN_PIXELS = 500
687
+ MATCH_GAIN_MIN = 0.5
688
+ MATCH_GAIN_MAX = 2.0
689
+ MATCH_MAX_EDGE_CONTRACT_PX = 20
690
+ MATCH_MAX_EDGE_FEATHER_PX = 50
691
+ MATCH_SURROUND_MIN_PX = 16
692
+ MATCH_SURROUND_MAX_PX = 400
693
+ # A std-dev below this (0-255 units) is treated as a flat region: its contrast ratio is
694
+ # meaningless, so the gain stays 1 and only the mean moves.
695
+ MATCH_FLAT_SD = 0.5
696
+ # Spacing of the helper points that keep a clamped curve on its line (see match_curve_points).
697
+ MATCH_CURVE_STEP = 16.0
698
+ # Measuring runs on a copy of the layer's region scaled so its long side is at most this: means
699
+ # and spreads survive the downscale, and growing the surround ring stays cheap at any size.
700
+ MATCH_MEASURE_MAX_SIDE = 2048
701
+
702
+
703
+ def default_surround_px(layer_width, layer_height):
704
+ """About 12% of the layer's longer side, clamped to 16..400 px."""
705
+ px = int(round(0.12 * max(layer_width, layer_height)))
706
+ return max(MATCH_SURROUND_MIN_PX, min(MATCH_SURROUND_MAX_PX, px))
707
+
708
+
709
+ def validate_match_args(args, layer_width, layer_height):
710
+ """The validated, defaulted `match_layer` options as a dict:
711
+ match/strength/reference/surround_px/edge_contract_px/edge_feather_px. `strength` is returned
712
+ as a 0..1 fraction under `s` as well."""
713
+ match = validate_choice('match', args.get('match') or 'both', MATCH_MODES)
714
+ reference = validate_choice('reference', args.get('reference') or 'surround', MATCH_REFERENCES)
715
+ strength = args.get('strength')
716
+ strength = validate_range(
717
+ 'strength', MATCH_DEFAULT_STRENGTH if strength is None else strength, 0.0, 100.0
718
+ )
719
+ surround = args.get('surround_px')
720
+ surround = (
721
+ default_surround_px(layer_width, layer_height)
722
+ if surround is None
723
+ else validate_int_range('surround_px', surround, 1, MATCH_SURROUND_MAX_PX)
724
+ )
725
+ contract = args.get('edge_contract_px')
726
+ contract = validate_int_range(
727
+ 'edge_contract_px', 0 if contract is None else contract, 0, MATCH_MAX_EDGE_CONTRACT_PX
728
+ )
729
+ feather = args.get('edge_feather_px')
730
+ feather = validate_int_range(
731
+ 'edge_feather_px', 0 if feather is None else feather, 0, MATCH_MAX_EDGE_FEATHER_PX
732
+ )
733
+ return {
734
+ 'match': match, 'reference': reference, 'strength': strength, 's': strength / 100.0,
735
+ 'surround_px': surround, 'edge_contract_px': contract, 'edge_feather_px': feather,
736
+ }
737
+
738
+
739
+ def match_transfer(mu_src, sd_src, mu_ref, sd_ref, s):
740
+ """The Reinhard-style linear map moving the source's mean/std toward the reference's, scaled
741
+ by `s` (0..1): out = (in - mu_src) * gain + mu_src + shift, with
742
+ gain = clamp(1 + (sd_ref / sd_src - 1) * s, 0.5, 2.0) and shift = (mu_ref - mu_src) * s.
743
+ A flat source (or reference) keeps gain 1. Returns {gain, shift, mean_after, std_after};
744
+ the `_after` values ignore the 0-255 clamp of the curve."""
745
+ if sd_src < MATCH_FLAT_SD or sd_ref < MATCH_FLAT_SD:
746
+ ratio = 1.0
747
+ else:
748
+ ratio = sd_ref / sd_src
749
+ gain = max(MATCH_GAIN_MIN, min(MATCH_GAIN_MAX, 1.0 + (ratio - 1.0) * s))
750
+ shift = (mu_ref - mu_src) * s
751
+ return {
752
+ 'gain': gain, 'shift': shift,
753
+ 'mean_after': mu_src + shift, 'std_after': sd_src * gain,
754
+ }
755
+
756
+
757
+ def match_curve_points(mu_src, gain, shift):
758
+ """The transfer map as gimp:curves [input, output] points (0-255, strictly ascending input).
759
+ The line y = gain * (x - mu_src) + mu_src + shift is clamped to 0..255. Unclamped it is just
760
+ its two endpoints (a two-point curve is exactly straight; gain 1 and shift 0 gives
761
+ [[0, 0], [255, 255]]). Where the line meets the clamp, a point is added there and helper
762
+ points every MATCH_CURVE_STEP along the sloped part: GIMP draws a smooth spline through the
763
+ points, and without the helpers it bows away from the line (measured: ~6 levels at the
764
+ middle of a clamped curve)."""
765
+ def y_at(x):
766
+ return gain * (x - mu_src) + mu_src + shift
767
+
768
+ def clamp(y):
769
+ return max(0.0, min(255.0, y))
770
+
771
+ y0, y1 = y_at(0.0), y_at(255.0)
772
+ xs = [0.0, 255.0]
773
+ if y0 < 0.0 or y1 > 255.0:
774
+ # y is strictly increasing (gain > 0): it crosses 0 once and 255 once.
775
+ low = 0.0 if y0 >= 0.0 else mu_src - (mu_src + shift) / gain
776
+ high = 255.0 if y1 <= 255.0 else mu_src + (255.0 - mu_src - shift) / gain
777
+ knots = [x for x in (low, high) if MATCH_CURVE_STEP / 4 < x < 255.0 - MATCH_CURVE_STEP / 4]
778
+ xs.extend(knots)
779
+ # Helpers fill the sloped part [low, high] only, kept clear of the knots.
780
+ x = MATCH_CURVE_STEP * (int(low // MATCH_CURVE_STEP) + 1)
781
+ while x < 255.0:
782
+ if low + MATCH_CURVE_STEP / 4 < x < high - MATCH_CURVE_STEP / 4 and x > MATCH_CURVE_STEP / 4:
783
+ xs.append(float(x))
784
+ x += MATCH_CURVE_STEP
785
+ xs = sorted(set(xs))
786
+ return [[round(x, 2), round(clamp(y_at(x)), 2)] for x in xs]
787
+
788
+
789
+ def match_measure_scale(width, height):
790
+ """The factor (0 < f <= 1) a measuring copy of a `width` x `height` region is scaled by."""
791
+ longest = max(width, height)
792
+ return 1.0 if longest <= MATCH_MEASURE_MAX_SIDE else MATCH_MEASURE_MAX_SIDE / float(longest)
793
+
794
+
795
+ def match_scaled_px(px, factor):
796
+ """A pixel distance on a copy scaled by `factor`, never below 1 for a non-zero distance."""
797
+ return 0 if px <= 0 else max(1, int(round(px * factor)))
798
+
799
+
800
+ def match_full_res_count(count, factor):
801
+ """A pixel count measured on a copy scaled by `factor`, as the full-resolution count."""
802
+ return int(round(count / (factor * factor)))
803
+
804
+
805
+ def histogram_scale(nick):
806
+ """What a Drawable.histogram mean/std must be multiplied by to read 0-255: GIMP reports them
807
+ in 0-255 for 8-bit precisions and in 0-1 for every higher one."""
808
+ return 1.0 if nick.startswith('u8') else 255.0
809
+
810
+
811
+ def is_linear_precision(nick):
812
+ """Whether a GIMP precision nick ('u8-non-linear', 'float-linear', ...) stores linear light."""
813
+ return nick.endswith('-linear') and not nick.endswith('-non-linear')
814
+
815
+
816
+ def match_roi(layer_x, layer_y, layer_width, layer_height, canvas_width, canvas_height, pad):
817
+ """The layer's box grown by `pad` and clipped to the canvas, as (x, y, width, height); None
818
+ when the layer's own box does not touch the canvas."""
819
+ if (layer_x >= canvas_width or layer_y >= canvas_height
820
+ or layer_x + layer_width <= 0 or layer_y + layer_height <= 0):
821
+ return None
822
+ x0 = max(0, layer_x - pad)
823
+ y0 = max(0, layer_y - pad)
824
+ x1 = min(canvas_width, layer_x + layer_width + pad)
825
+ y1 = min(canvas_height, layer_y + layer_height + pad)
826
+ return x0, y0, x1 - x0, y1 - y0
827
+
828
+
306
829
  _HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
307
830
 
308
831
  # gimp_canvas's fill choices: the same white/black/transparent LAYER_FILLS a new layer gets, plus
@@ -319,6 +842,57 @@ def validate_canvas_fill(value):
319
842
  )
320
843
 
321
844
 
845
+ def validate_hex_color(name, value):
846
+ """A '#rrggbb' hex color -- the same `_HEX_COLOR_RE` gimp_canvas's own fill validates against,
847
+ reused here so a color field never silently reaches `Gegl.Color.new` with a string it cannot
848
+ parse (which would otherwise fall back to black)."""
849
+ if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value):
850
+ raise ValueError('%s must be a "#rrggbb" hex color, got %r' % (name, value))
851
+ return value
852
+
853
+
854
+ def _round_half_up(value):
855
+ """Round half away from zero, unlike Python's builtin `round()` (round-half-to-even:
856
+ `round(0.5) == 0`, `round(2.5) == 2`) -- surprising for a user-facing pixel radius, where
857
+ "4.5 px" should become 5, not 4."""
858
+ return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
859
+
860
+
861
+ def validate_positive_px(name, value, max_px=MAX_FEATHER_PX):
862
+ """A strictly positive pixel radius (expand/contract/border/feather). Rounds half-up to the
863
+ nearest whole pixel FIRST, then refuses anything that is not strictly positive or exceeds
864
+ max_px -- so a value that ROUNDS to 0 (e.g. 0.3) is refused, same as an outright 0 or
865
+ negative, rather than silently accepted as a no-op radius."""
866
+ rounded = int(_round_half_up(float(value)))
867
+ if not 0 < rounded <= max_px:
868
+ raise ValueError('%s must round to a value greater than 0 and at most %s' % (name, max_px))
869
+ return rounded
870
+
871
+
872
+ # load_mask's own DoS floor on a SOURCE mask image's dimensions. Checked right after
873
+ # `Gimp.file_load` decodes the file (GIMP's Python environment has no cheap way to read a PNG/
874
+ # JPEG header's own width/height without decoding -- GdkPixbuf is not bound in this environment),
875
+ # so it bounds the IN-MEMORY size before the heavier work that follows (alpha compositing, scale,
876
+ # flatten), not the decode itself. Reuses the same cap `validate_resize_dims` applies to a resize
877
+ # target, since both are "how big a single in-memory image may get" limits.
878
+ def validate_loaded_mask_dims(width, height):
879
+ return validate_resize_dims(width, height)
880
+
881
+
882
+ def compute_mask_paste_rect(ox, oy, tw, th, img_width, img_height):
883
+ """Where a `tw`x`th` mask (already scaled to its target -- the whole image, or one layer's own
884
+ bounds, at offset `(ox, oy)`) actually lands inside the `img_width`x`img_height` canvas: the
885
+ intersection of [ox, ox+tw) x [oy, oy+th) with [0, img_width) x [0, img_height). Pure and
886
+ gi-free so the off-canvas cases (a negative offset, or a layer hanging off the right/bottom
887
+ edge) are unit-tested without GIMP. Returns (x0, y0, x1, y1); x1 <= x0 or y1 <= y0 means the
888
+ mask lands ENTIRELY off-canvas (nothing to paste -- the channel stays all-black there)."""
889
+ x0 = max(0, ox)
890
+ y0 = max(0, oy)
891
+ x1 = min(img_width, ox + tw)
892
+ y1 = min(img_height, oy + th)
893
+ return x0, y0, x1, y1
894
+
895
+
322
896
  # gimp_canvas's anchor grid -> the fraction of the GROWTH (new size minus old size) that lands
323
897
  # BEFORE the existing content on each axis. 0.0 pins that edge (no padding there); 1.0 puts all
324
898
  # the padding there instead; 0.5 splits it evenly. Keyed by the full 3x3 grid a Photoshop-style
@@ -350,6 +924,69 @@ def canvas_anchor_offset(anchor, old_width, old_height, new_width, new_height):
350
924
  )
351
925
 
352
926
 
927
+ # EXIF Orientation (1-8) -> the steps that turn the stored pixels upright, in order. Each step is
928
+ # 'cw90' / 'cw180' / 'cw270' (clockwise rotation), 'flip_h' (mirror left-right) or 'flip_v'
929
+ # (mirror top-bottom). 1 (normal) needs none.
930
+ EXIF_ORIENTATION_STEPS = {
931
+ 1: (),
932
+ 2: ('flip_h',),
933
+ 3: ('cw180',),
934
+ 4: ('flip_v',),
935
+ 5: ('cw90', 'flip_h'),
936
+ 6: ('cw90',),
937
+ 7: ('cw90', 'flip_v'),
938
+ 8: ('cw270',),
939
+ }
940
+
941
+
942
+ def parse_exif_orientation(raw):
943
+ """The EXIF Orientation value (1-8) from a metadata tag's raw value, or None when it is
944
+ missing, non-numeric or out of range -- a broken tag is ignored, never an error."""
945
+ try:
946
+ value = int(str(raw).strip())
947
+ except (TypeError, ValueError):
948
+ return None
949
+ return value if value in EXIF_ORIENTATION_STEPS else None
950
+
951
+
952
+ def exif_orientation_steps(orientation):
953
+ """The upright-making steps for an EXIF Orientation value; () for None, 1 or anything else."""
954
+ return EXIF_ORIENTATION_STEPS.get(orientation, ())
955
+
956
+
957
+ EXIF_ORIENTATION_TAG = 'Exif.Image.Orientation'
958
+
959
+
960
+ def read_exif_orientation(metadata):
961
+ """The orientation (1-8) a GimpMetadata-like object carries, or None when `metadata` is None,
962
+ has no usable tag, or reading it raises."""
963
+ if metadata is None:
964
+ return None
965
+ try:
966
+ return parse_exif_orientation(metadata.try_get_tag_string(EXIF_ORIENTATION_TAG))
967
+ except Exception:
968
+ return None
969
+
970
+
971
+ # Every tag a viewer may read orientation from; the EXIF one is the one this bridge applies.
972
+ ORIENTATION_TAGS = (EXIF_ORIENTATION_TAG, 'Xmp.tiff.Orientation', 'Exif.Thumbnail.Orientation')
973
+
974
+
975
+ def clear_exif_orientation(metadata):
976
+ """Removes the orientation tags from `metadata` (absent reads as normal); True when the EXIF
977
+ one was cleared. Clearing is used rather than writing 1: GIMP's metadata object can silently
978
+ ignore a write of that tag while still reporting success."""
979
+ cleared = False
980
+ for tag in ORIENTATION_TAGS:
981
+ try:
982
+ ok = bool(metadata.try_clear_tag(tag))
983
+ except Exception:
984
+ ok = False
985
+ if tag == EXIF_ORIENTATION_TAG:
986
+ cleared = ok
987
+ return cleared
988
+
989
+
353
990
  def pct_to_unit(name, value, lo=-100.0, hi=100.0):
354
991
  """User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
355
992
  GEGL/GIMP properties in this family use."""
@@ -492,10 +1129,18 @@ def build_saturation_params(args, defaults):
492
1129
  }
493
1130
 
494
1131
 
1132
+ # gegl:vibrance's own -100..100 is far stronger than the Photoshop scale callers expect:
1133
+ # on a marina photo its +15 sent 38% of pixels to red 0, where Photoshop's +15 added about 0.014
1134
+ # mean saturation and clipped nothing. Dividing by this factor lines the two up (Photoshop +15 and
1135
+ # +50 match gegl about 0.5 and 2.0 on that photo), so the field keeps a Photoshop-like -100..100.
1136
+ VIBRANCE_GEGL_DIVISOR = 25.0
1137
+
1138
+
495
1139
  def build_vibrance_params(args, defaults):
496
1140
  return {
497
1141
  'vibrance': resolve_field(
498
- args, 'vibrance', defaults, 'vibrance', lambda v: validate_range('vibrance', v, -100.0, 100.0)
1142
+ args, 'vibrance', defaults, 'vibrance',
1143
+ lambda v: validate_range('vibrance', v, -100.0, 100.0) / VIBRANCE_GEGL_DIVISOR,
499
1144
  ),
500
1145
  'saturation': resolve_field(
501
1146
  args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
@@ -609,13 +1254,35 @@ EFFECT_OPERATIONS = {
609
1254
  'lens_blur': 'gegl:focus-blur',
610
1255
  'add_noise': 'gegl:noise-rgb',
611
1256
  'drop_shadow': 'gegl:dropshadow',
1257
+ # outline and outer_glow are also gegl:dropshadow (zero offset, grown by grow-radius): one
1258
+ # operation, three types -- the ledger's `type` tells them apart, see OPERATION_TYPES below.
1259
+ 'outline': 'gegl:dropshadow',
1260
+ 'outer_glow': 'gegl:dropshadow',
1261
+ 'surface_blur': 'gegl:bilateral-filter',
612
1262
  }
613
1263
 
614
1264
  # The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
615
1265
  # without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
616
- # here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it.
1266
+ # here regardless of which tool (gimp_add_adjustment or gimp_add_effect) created it. Where several
1267
+ # types share one operation (gegl:dropshadow), the first listed wins: a record with no `type` is
1268
+ # from before outline/outer_glow existed, so it can only be a drop_shadow. THAT DEPENDS ON
1269
+ # INSERTION ORDER: `setdefault` keeps whichever type reached the map first, so 'drop_shadow' must
1270
+ # stay listed above 'outline' and 'outer_glow' in EFFECT_OPERATIONS (test_lib.py pins it).
617
1271
  OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
618
- OPERATION_TYPES.update({operation: type_ for type_, operation in EFFECT_OPERATIONS.items()})
1272
+ for _type, _operation in EFFECT_OPERATIONS.items():
1273
+ OPERATION_TYPES.setdefault(_operation, _type)
1274
+ del _type, _operation
1275
+
1276
+
1277
+ def record_type(rec):
1278
+ """The tool `type` of a ledger filter record: its stored `type`, or -- for a record written
1279
+ before `type` was stored -- the type its operation implies (`OPERATION_TYPES`). None for an
1280
+ operation neither tool owns. The one place both the filter listing and the effect re-edit
1281
+ type guard resolve a record's type, so a typeless legacy gegl:dropshadow reads as drop_shadow
1282
+ in both."""
1283
+ if not isinstance(rec, dict):
1284
+ return None
1285
+ return rec.get('type') or OPERATION_TYPES.get(rec.get('operation'))
619
1286
 
620
1287
  # Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
621
1288
  # parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
@@ -770,6 +1437,79 @@ def build_drop_shadow_params(args, defaults):
770
1437
  }
771
1438
 
772
1439
 
1440
+ def build_outline_params(args, defaults):
1441
+ # A solid ring around the layer's alpha: gegl:dropshadow with no offset and no blur, grown by
1442
+ # `width` px (grow-radius, a circle) at full opacity. Only `grow-radius` and `color` are
1443
+ # recorded; `_set_drop_shadow` fills the rest in (offset 0, blur 0, opacity 1). The shadow is
1444
+ # drawn under the layer's own pixels, so the interior is untouched.
1445
+ #
1446
+ # GEGL allows grow-radius up to 100, but its cost grows linearly with the radius times the
1447
+ # pixel count. Measured live (GIMP 3.2.6, a 6000x4000 = 24 MP layer, full export incl. a ~1 s
1448
+ # baseline export with no filter): width 6 -> 4 s, 50 -> 18.5 s, 100 -> 39.5 s; outer_glow at
1449
+ # radius 250 with spread 50 -> 17.7 s, spread 100 -> 30.6 s. Width and spread stop at 50, the
1450
+ # same ~20 s worst case on 24 MP as SURFACE_BLUR_MAX_RADIUS.
1451
+ return {
1452
+ 'grow-radius': resolve_field(
1453
+ args, 'width', defaults, 'grow-radius', lambda val: validate_range('width', val, 1.0, 50.0)
1454
+ ),
1455
+ 'color': resolve_field(
1456
+ args, 'color', defaults, 'color', lambda val: validate_hex_color('color', val)
1457
+ ),
1458
+ }
1459
+
1460
+
1461
+ def build_outer_glow_params(args, defaults):
1462
+ # The same gegl:dropshadow, centered (no offset, filled in by `_set_drop_shadow`): `spread`
1463
+ # grows the glow's solid core, `radius` blurs it.
1464
+ return {
1465
+ 'radius': resolve_field(
1466
+ args, 'radius', defaults, 'radius', lambda val: validate_range('radius', val, 1.0, 250.0)
1467
+ ),
1468
+ 'grow-radius': resolve_field(
1469
+ args, 'spread', defaults, 'grow-radius',
1470
+ lambda val: validate_range('spread', val, 0.0, 50.0),
1471
+ ),
1472
+ 'opacity': resolve_field(
1473
+ args, 'opacity', defaults, 'opacity', lambda val: validate_range('opacity', val, 0.0, 1.0)
1474
+ ),
1475
+ 'color': resolve_field(
1476
+ args, 'color', defaults, 'color', lambda val: validate_hex_color('color', val)
1477
+ ),
1478
+ }
1479
+
1480
+
1481
+ # gegl:bilateral-filter's `edge-preservation` runs 0..100 (default 8), where HIGHER keeps more of
1482
+ # the original, edges and noise alike -- probed live on a noisy patch beside a hard edge: 0 smooths
1483
+ # hard enough to blur the edge itself, 8 flattens the noise (stddev 17 -> 2) with the edge intact,
1484
+ # 100 barely smooths. The tool exposes it as 0..1 (this factor times the tool value).
1485
+ # gegl:mean-curvature-blur and gegl:snn-mean were not needed: GIMP attaches bilateral-filter fine.
1486
+ BILATERAL_EDGE_SCALE = 100.0
1487
+
1488
+
1489
+ # gegl:bilateral-filter's cost grows with the SQUARE of the radius times the pixel count. Measured
1490
+ # live (GIMP 3.2.6, this dev box, a noisy 6016x4000 = 24 MP layer; full export incl. a ~2 s
1491
+ # baseline export with no filter): radius 5 -> 12 s, 6 -> 17 s, 7 -> 23 s, 8 -> 28 s, 10 -> 41 s;
1492
+ # radius 25 and 50 were not finished inside a 400 s timeout (extrapolating the r^2 trend: ~4 and
1493
+ # ~16 minutes). The tool's ~20 s worst case on 24 MP therefore stops at radius 6, and the remote
1494
+ # box is slower still. A smaller image has headroom (cost scales with pixels) but the cap is a flat
1495
+ # per-filter number: it does not look at the layer. TS schema, EFFECT_TRANSFORM_BOUNDS and this
1496
+ # builder share it.
1497
+ SURFACE_BLUR_MAX_RADIUS = 6.0
1498
+
1499
+
1500
+ def build_surface_blur_params(args, defaults):
1501
+ return {
1502
+ 'blur-radius': resolve_field(
1503
+ args, 'radius', defaults, 'blur-radius',
1504
+ lambda val: validate_range('radius', val, 1.0, SURFACE_BLUR_MAX_RADIUS),
1505
+ ),
1506
+ 'edge-preservation': resolve_field(
1507
+ args, 'edge_preservation', defaults, 'edge-preservation',
1508
+ lambda val: validate_range('edge_preservation', val, 0.0, 1.0),
1509
+ ),
1510
+ }
1511
+
1512
+
773
1513
  EFFECT_PARAM_BUILDERS = {
774
1514
  'vignette': build_vignette_params,
775
1515
  'black_white': build_black_white_params,
@@ -777,6 +1517,9 @@ EFFECT_PARAM_BUILDERS = {
777
1517
  'lens_blur': build_lens_blur_params,
778
1518
  'add_noise': build_add_noise_params,
779
1519
  'drop_shadow': build_drop_shadow_params,
1520
+ 'outline': build_outline_params,
1521
+ 'outer_glow': build_outer_glow_params,
1522
+ 'surface_blur': build_surface_blur_params,
780
1523
  }
781
1524
 
782
1525
  # Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
@@ -790,6 +1533,9 @@ EFFECT_CREATE_DEFAULTS = {
790
1533
  'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
791
1534
  'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
792
1535
  'drop_shadow': {'x': 20.0, 'y': 20.0, 'radius': 10.0, 'opacity': 0.5},
1536
+ 'outline': {'grow-radius': 4.0, 'color': '#000000'},
1537
+ 'outer_glow': {'radius': 20.0, 'grow-radius': 4.0, 'opacity': 0.75, 'color': '#ffffff'},
1538
+ 'surface_blur': {'blur-radius': 4.0, 'edge-preservation': 0.08},
793
1539
  }
794
1540
 
795
1541
 
@@ -846,7 +1592,7 @@ USER_FIELDS = {
846
1592
  ),
847
1593
  'saturation': (('scale', 'scale', _same),),
848
1594
  'vibrance': (
849
- ('vibrance', 'vibrance', _same),
1595
+ ('vibrance', 'vibrance', _scaled(VIBRANCE_GEGL_DIVISOR)),
850
1596
  ('saturation', 'saturation', _same),
851
1597
  ),
852
1598
  'sharpen': (
@@ -889,6 +1635,20 @@ USER_FIELDS = {
889
1635
  ('radius', 'radius', _same),
890
1636
  ('opacity', 'opacity', _same),
891
1637
  ),
1638
+ 'outline': (
1639
+ ('width', 'grow-radius', _same),
1640
+ ('color', 'color', _same),
1641
+ ),
1642
+ 'outer_glow': (
1643
+ ('spread', 'grow-radius', _same),
1644
+ ('radius', 'radius', _same),
1645
+ ('opacity', 'opacity', _same),
1646
+ ('color', 'color', _same),
1647
+ ),
1648
+ 'surface_blur': (
1649
+ ('radius', 'blur-radius', _same),
1650
+ ('edge_preservation', 'edge-preservation', _same),
1651
+ ),
892
1652
  }
893
1653
 
894
1654
  CURVES_USER_FIELDS = ('channel', 'points')
@@ -975,7 +1735,6 @@ def json_safe(value):
975
1735
  # operations this engine actually uses.
976
1736
  ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
977
1737
 
978
-
979
1738
  # ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
980
1739
 
981
1740
 
@@ -1208,7 +1967,7 @@ def flip_effect_params(operation, params, orientation):
1208
1967
  else:
1209
1968
  params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
1210
1969
  return params
1211
- if operation == 'gegl:dropshadow':
1970
+ if operation == 'gegl:dropshadow' and 'x' in params: # outline / outer_glow have no offset
1212
1971
  params = dict(params)
1213
1972
  if orientation == 'horizontal':
1214
1973
  params['x'] = _round_transform_value(-params['x'])
@@ -1245,7 +2004,7 @@ def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
1245
2004
  canonical_degrees = _right_angle_k(degrees) * 90.0
1246
2005
  params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
1247
2006
  return params
1248
- if operation == 'gegl:dropshadow':
2007
+ if operation == 'gegl:dropshadow' and 'x' in params:
1249
2008
  params = dict(params)
1250
2009
  cos_t, sin_t = _right_angle_cos_sin(degrees)
1251
2010
  dx, dy = params['x'], params['y']
@@ -1295,9 +2054,18 @@ def resize_effect_params(operation, params, scale_x, scale_y):
1295
2054
  if operation == 'gegl:dropshadow':
1296
2055
  params = dict(params)
1297
2056
  isotropic_scale = math.sqrt(scale_x * scale_y)
1298
- params['x'] = _round_transform_value(params['x'] * scale_x)
1299
- params['y'] = _round_transform_value(params['y'] * scale_y)
1300
- params['radius'] = _round_transform_value(params['radius'] * isotropic_scale)
2057
+ # outline has only grow-radius, outer_glow lacks the offset: scale what is recorded.
2058
+ for prop, factor in (
2059
+ ('x', scale_x), ('y', scale_y), ('radius', isotropic_scale),
2060
+ ('grow-radius', isotropic_scale),
2061
+ ):
2062
+ if prop in params:
2063
+ params[prop] = _round_transform_value(params[prop] * factor)
2064
+ return params
2065
+ if operation == 'gegl:bilateral-filter':
2066
+ params = dict(params)
2067
+ isotropic_scale = math.sqrt(scale_x * scale_y)
2068
+ params['blur-radius'] = _round_transform_value(params['blur-radius'] * isotropic_scale)
1301
2069
  return params
1302
2070
  return params
1303
2071
 
@@ -1311,7 +2079,12 @@ EFFECT_TRANSFORM_BOUNDS = {
1311
2079
  'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
1312
2080
  'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
1313
2081
  'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
1314
- 'gegl:dropshadow': {'x': (-500.0, 500.0), 'y': (-500.0, 500.0), 'radius': (0.0, 1500.0)},
2082
+ 'gegl:dropshadow': {
2083
+ 'x': (-500.0, 500.0), 'y': (-500.0, 500.0), 'radius': (0.0, 1500.0),
2084
+ # outline's width and outer_glow's spread share a cap (see build_outline_params).
2085
+ 'grow-radius': (0.0, 50.0),
2086
+ },
2087
+ 'gegl:bilateral-filter': {'blur-radius': (0.0, SURFACE_BLUR_MAX_RADIUS)},
1315
2088
  }
1316
2089
 
1317
2090
  # The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
@@ -1328,10 +2101,44 @@ EFFECT_TRANSFORM_FIELD_NAMES = {
1328
2101
  ('gegl:dropshadow', 'x'): 'offset_x',
1329
2102
  ('gegl:dropshadow', 'y'): 'offset_y',
1330
2103
  ('gegl:dropshadow', 'radius'): 'radius',
2104
+ ('gegl:dropshadow', 'grow-radius'): 'width/spread',
2105
+ ('gegl:bilateral-filter', 'blur-radius'): 'radius',
1331
2106
  }
1332
2107
 
1333
2108
 
1334
- def validate_effect_transform(op_name, operation, filter_name, new_params):
2109
+ # Per-type floors a re-edit enforces above EFFECT_TRANSFORM_BOUNDS' own lower bound (those are
2110
+ # per operation, and gegl:dropshadow serves three types): shrinking resize would otherwise store a
2111
+ # value a re-edit of the same filter refuses. A resize that bottoms out is clamped to the floor
2112
+ # (the 1 px minimum the effect itself allows) instead of refused: the filter is still valid, only
2113
+ # no thinner than the tool can express.
2114
+ EFFECT_TRANSFORM_FLOORS = {
2115
+ ('outline', 'grow-radius'): 1.0,
2116
+ ('outer_glow', 'radius'): 1.0,
2117
+ ('surface_blur', 'blur-radius'): 1.0,
2118
+ }
2119
+
2120
+
2121
+ # Per-type ceilings a re-edit enforces below EFFECT_TRANSFORM_BOUNDS' own upper bound: outer_glow
2122
+ # shares gegl:dropshadow (radius up to 1500) but its own builder caps radius at 250, so a growing
2123
+ # resize would otherwise store a radius a re-edit of the same glow refuses. Unlike a floor, a
2124
+ # value past a ceiling is refused, the same as any other out-of-range transform.
2125
+ EFFECT_TRANSFORM_CEILINGS = {
2126
+ ('outer_glow', 'radius'): 250.0,
2127
+ }
2128
+
2129
+
2130
+ def clamp_effect_transform(type_, new_params):
2131
+ """`new_params` with every EFFECT_TRANSFORM_FLOORS entry for `type_` applied (the same dict
2132
+ when nothing needed clamping, so callers' `==` change check stays honest)."""
2133
+ clamped = None
2134
+ for (floor_type, prop), floor in EFFECT_TRANSFORM_FLOORS.items():
2135
+ if floor_type == type_ and prop in new_params and new_params[prop] < floor:
2136
+ clamped = clamped if clamped is not None else dict(new_params)
2137
+ clamped[prop] = floor
2138
+ return new_params if clamped is None else clamped
2139
+
2140
+
2141
+ def validate_effect_transform(op_name, operation, filter_name, new_params, type_=None):
1335
2142
  """Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
1336
2143
  computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
1337
2144
  would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
@@ -1339,10 +2146,12 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
1339
2146
  range never gets a chance to silently clamp via GObject's own property setter instead of a
1340
2147
  clear, actionable refusal -- and the geometry op never partially applies. Names the filter
1341
2148
  (`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
1342
- effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name."""
2149
+ effect's OWN terms (`EFFECT_TRANSFORM_FIELD_NAMES`), not the raw GEGL property name. `type_`
2150
+ (the record's effect type) adds that type's EFFECT_TRANSFORM_CEILINGS."""
1343
2151
  for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
1344
2152
  if prop not in new_params:
1345
2153
  continue
2154
+ hi = min(hi, EFFECT_TRANSFORM_CEILINGS.get((type_, prop), hi))
1346
2155
  value = new_params[prop]
1347
2156
  if not lo <= value <= hi:
1348
2157
  field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
@@ -1358,6 +2167,155 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
1358
2167
  )
1359
2168
 
1360
2169
 
2170
+
2171
+ # ---- gimp_transform_layer: pure geometry math (fit/fill scale, matrix composition, bounds) ----
2172
+ #
2173
+ # A layer-level affine transform (scale/rotate/skew/free), distinct from the whole-CANVAS
2174
+ # geometry ops above and from the direction/position-dependent EFFECT param remapping above.
2175
+ # `Item.transform_matrix` (ops.py) applies the given 3x3 matrix directly in ABSOLUTE
2176
+ # document-pixel coordinates -- verified live (GIMP 3.2.6): a matrix built by
2177
+ # `compose_layer_matrix` below, applied to a layer at a known offset, moved it to exactly the
2178
+ # bounding box this module's own `transformed_bounds` predicts for the same matrix and corners
2179
+ # (within floating-point rounding). So every matrix built here already carries whatever
2180
+ # translation is needed to anchor the transform at the layer's own center; there is no separate
2181
+ # "local" coordinate frame to convert into or out of first.
2182
+
2183
+
2184
+ def fit_scale_fraction(layer_width, layer_height, canvas_width, canvas_height, mode):
2185
+ """The uniform scale fraction (1.0 = 100%) that makes a `layer_width` x `layer_height`
2186
+ rectangle fit inside (mode='fit', letterbox -- the SMALLER of the two axis ratios) or fill
2187
+ (mode='fill', cover -- the LARGER) a `canvas_width` x `canvas_height` canvas, preserving
2188
+ aspect. `op_transform_layer`'s `fit` scales by this fraction and centers the result; fitting
2189
+ an already-fitted layer computes a fraction of 1.0, a no-op scale -- what makes that op
2190
+ idempotent."""
2191
+ if mode not in ('fit', 'fill'):
2192
+ raise ValueError("mode must be one of 'fit', 'fill'")
2193
+ width_ratio = canvas_width / float(layer_width)
2194
+ height_ratio = canvas_height / float(layer_height)
2195
+ return min(width_ratio, height_ratio) if mode == 'fit' else max(width_ratio, height_ratio)
2196
+
2197
+
2198
+ def _mat_mul(a, b):
2199
+ """3x3 matrix product, both `a` and `b` flat row-major 9-lists (the same layout
2200
+ `Item.transform_matrix`'s own 9 positional args use), applied to a column vector [x,y,1] as
2201
+ p' = a*(b*p) -- `b` is the transform applied FIRST."""
2202
+ return [
2203
+ a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
2204
+ a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
2205
+ a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
2206
+ a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
2207
+ a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
2208
+ a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
2209
+ a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
2210
+ a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
2211
+ a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
2212
+ ]
2213
+
2214
+
2215
+ def _mat_translate(tx, ty):
2216
+ return [1.0, 0.0, tx, 0.0, 1.0, ty, 0.0, 0.0, 1.0]
2217
+
2218
+
2219
+ def _mat_scale(sx, sy):
2220
+ return [sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]
2221
+
2222
+
2223
+ def _mat_rotate(degrees):
2224
+ theta = math.radians(degrees)
2225
+ cos_t, sin_t = math.cos(theta), math.sin(theta)
2226
+ return [cos_t, -sin_t, 0.0, sin_t, cos_t, 0.0, 0.0, 0.0, 1.0]
2227
+
2228
+
2229
+ def _mat_shear_h(skew_h_degrees):
2230
+ """A pure horizontal shear: x' = x - tan(skew_h_degrees)*y, y' = y -- determinant exactly 1
2231
+ for any angle. Positive `skew_h_degrees` slants the top edge right (a point above center,
2232
+ smaller y, moves toward +x) -- the convention `gimp_transform_layer`'s own schema documents,
2233
+ and the one `ps_transform_layer`'s op=skew uses."""
2234
+ return [1.0, -math.tan(math.radians(skew_h_degrees)), 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
2235
+
2236
+
2237
+ def _mat_shear_v(skew_v_degrees):
2238
+ """A pure vertical shear: x' = x, y' = -tan(skew_v_degrees)*x + y -- determinant exactly 1
2239
+ for any angle. Positive `skew_v_degrees` slants the left edge down (a point left of center,
2240
+ smaller x, moves toward +y)."""
2241
+ return [1.0, 0.0, 0.0, -math.tan(math.radians(skew_v_degrees)), 1.0, 0.0, 0.0, 0.0, 1.0]
2242
+
2243
+
2244
+ def _mat_determinant(m):
2245
+ """The determinant of `m`'s linear (non-translation) 2x2 part -- the translation column
2246
+ (indices 2, 5) never affects it. Zero means the transform collapses the rectangle to a line
2247
+ or a point; negative means it mirrors (flips handedness) rather than purely scaling/
2248
+ rotating/shearing it."""
2249
+ return m[0] * m[4] - m[1] * m[3]
2250
+
2251
+
2252
+ # A composed matrix below this |determinant| is treated the same as an exact 0: numerically
2253
+ # indistinguishable from a transform that collapses the layer to a line, so refused outright
2254
+ # rather than handed to `Item.transform_matrix` to silently produce a degenerate result.
2255
+ TRANSFORM_LAYER_MIN_DETERMINANT = 1e-6
2256
+
2257
+
2258
+ def compose_layer_matrix(cx, cy, scale_x_percent, scale_y_percent, degrees,
2259
+ skew_h_degrees, skew_v_degrees, offset_x, offset_y):
2260
+ """The 9 row-major coefficients for `Item.transform_matrix`: a scale (percent, 100 =
2261
+ unchanged), then a skew (skew_h_degrees/skew_v_degrees, composed as two independent real
2262
+ shears -- Sh_v . Sh_h, each with its OWN determinant of exactly 1, so the composed skew's
2263
+ determinant is always exactly 1 too, for any angle), then a rotation (degrees, clockwise),
2264
+ all anchored at the layer's own center (`cx`, `cy`, document pixels), followed by an absolute
2265
+ translation (`offset_x`, `offset_y`). Never the single combined-shear matrix
2266
+ [[1,-tan(h)],[-tan(v),1]] -- that matrix's OWN determinant is `1 - tan(h)*tan(v)`, which
2267
+ reaches exactly 0 at h=v=45 (collapsing the rectangle to a line) and goes NEGATIVE past that
2268
+ (silently mirroring it instead of shearing it). `op_transform_layer`'s `free` op calls this
2269
+ directly (scale + degrees + offset, no skew); `skew` calls it with scale 100/100, degrees 0,
2270
+ offset 0/0; `rotate`'s own bounds check (not its actual transform, which goes through
2271
+ `Item.transform_rotate` instead -- see ops.py) reuses it with scale 100/100, skew 0/0, offset
2272
+ 0/0 purely to predict the post-rotation bounding box via `transformed_bounds`.
2273
+
2274
+ Raises if the FINAL composed matrix's determinant is below `TRANSFORM_LAYER_MIN_DETERMINANT`
2275
+ or negative -- scale alone is already bounded away from both by its own 1..10000% range, but
2276
+ this is the belt-and-braces check against the composed result as a whole, not just its own
2277
+ skew component."""
2278
+ m = _mat_translate(-cx, -cy)
2279
+ m = _mat_mul(_mat_scale(scale_x_percent / 100.0, scale_y_percent / 100.0), m)
2280
+ m = _mat_mul(_mat_shear_v(skew_v_degrees), _mat_mul(_mat_shear_h(skew_h_degrees), m))
2281
+ m = _mat_mul(_mat_rotate(degrees), m)
2282
+ m = _mat_mul(_mat_translate(cx, cy), m)
2283
+ m = _mat_mul(_mat_translate(offset_x, offset_y), m)
2284
+ det = _mat_determinant(m)
2285
+ if not math.isfinite(det) or det < 0 or abs(det) < TRANSFORM_LAYER_MIN_DETERMINANT:
2286
+ raise ValueError(
2287
+ 'this combination of scale/skew/rotate collapses or mirrors the layer (determinant '
2288
+ '%.6g) instead of transforming it -- reduce the scale or skew and try again' % det
2289
+ )
2290
+ return m
2291
+
2292
+
2293
+ def transformed_bounds(matrix, x, y, width, height):
2294
+ """The axis-aligned bounding box of a `width` x `height` rectangle at document-pixel origin
2295
+ (`x`, `y`), after mapping each of its four corners through `matrix` (9 row-major
2296
+ coefficients, `Item.transform_matrix`'s own layout) -- what the transformed layer's new
2297
+ offsets/width/height will measure to. Used to validate a rotate/skew/free transform against
2298
+ the same size cap `validate_resize_dims` enforces, BEFORE any pixel actually moves. Returns
2299
+ (new_x, new_y, new_width, new_height)."""
2300
+ corners = ((x, y), (x + width, y), (x, y + height), (x + width, y + height))
2301
+ xs = [matrix[0] * cx + matrix[1] * cy + matrix[2] for cx, cy in corners]
2302
+ ys = [matrix[3] * cx + matrix[4] * cy + matrix[5] for cx, cy in corners]
2303
+ min_x, max_x = min(xs), max(xs)
2304
+ min_y, max_y = min(ys), max(ys)
2305
+ return min_x, min_y, max_x - min_x, max_y - min_y
2306
+
2307
+
2308
+ def validate_transformed_layer_size(width, height):
2309
+ """Refuses a scale/fit whose rounded target is under 1px in either dimension. Without this
2310
+ GIMP is handed a zero-size rectangle, returns nothing, and the caller reports a misleading
2311
+ "lock-position or lock-content" failure for a layer that was never locked."""
2312
+ if width < 1 or height < 1:
2313
+ raise ValueError(
2314
+ 'the transform would shrink the layer to %dx%d px after rounding; use a larger scale'
2315
+ % (width, height)
2316
+ )
2317
+
2318
+
1361
2319
  def region_to_proxy_px(region, scale):
1362
2320
  """Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
1363
2321
  `scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
@@ -1370,6 +2328,13 @@ def region_to_proxy_px(region, scale):
1370
2328
  return x, y, w, h
1371
2329
 
1372
2330
 
2331
+ def visible_image_ids(all_ids, hidden_ids):
2332
+ """The ids in `all_ids` that are real documents: every id in `hidden_ids` (the bridge's own
2333
+ preview-proxy images, which are live GIMP images but not the user's) is dropped, order kept."""
2334
+ hidden = set(hidden_ids)
2335
+ return [i for i in all_ids if i not in hidden]
2336
+
2337
+
1373
2338
  def read_pgm(raw):
1374
2339
  """Decode minimal binary PGM (P5, maxval 255) bytes. Returns (width, height, data).
1375
2340
 
@@ -1506,10 +2471,10 @@ def parse_ledger(raw):
1506
2471
  parasite bytes ARE the filters map directly (no wrapper). Treated as
1507
2472
  v1-compatible so files written before the ledger was versioned keep
1508
2473
  working.
1509
- - Versioned + "v" == LEDGER_VERSION: "filters" is the map; any OTHER
2474
+ - Versioned + "v" in 1..LEDGER_VERSION: "filters" is the map; any OTHER
1510
2475
  top-level key is returned as `unknown_top_level` so a future writer's
1511
2476
  extra fields round-trip untouched through serialize_ledger.
1512
- - Versioned + "v" != LEDGER_VERSION (in practice: greater, from a future
2477
+ - Versioned + "v" outside that range (in practice: greater, from a future
1513
2478
  writer): every filter is treated as absent — the caller falls back to
1514
2479
  readback for all of them, per the documented contract that we never
1515
2480
  guess at a schema we don't recognize. `unknown_top_level` still
@@ -1545,7 +2510,7 @@ def parse_ledger(raw):
1545
2510
  return _validate_filters(raw_filters), {}, raw_filters
1546
2511
  unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
1547
2512
  raw_filters = dict(doc['filters'])
1548
- if doc['v'] != LEDGER_VERSION:
2513
+ if not 1 <= doc['v'] <= LEDGER_VERSION:
1549
2514
  return {}, unknown, raw_filters
1550
2515
  return _validate_filters(raw_filters), unknown, raw_filters
1551
2516
 
@@ -1593,12 +2558,19 @@ def ledger_is_newer_version(raw):
1593
2558
  return doc['v'] > LEDGER_VERSION
1594
2559
 
1595
2560
 
2561
+ def ledger_write_version(filters_by_name):
2562
+ """The lowest ledger version that holds every record in `filters_by_name` (see LEDGER_VERSION)."""
2563
+ if any(record_type(rec) in LEDGER_V2_TYPES for rec in filters_by_name.values()):
2564
+ return 2
2565
+ return 1
2566
+
2567
+
1596
2568
  def serialize_ledger(filters_by_name, unknown_top_level=None):
1597
- """Serialize filters_by_name as the current-version ledger, preserving any
2569
+ """Serialize filters_by_name as a ledger at `ledger_write_version`, preserving any
1598
2570
  unknown_top_level keys carried over from parse_ledger byte-for-byte
1599
2571
  (as JSON values -- not literal bytes, but nothing under them is rewritten)."""
1600
2572
  doc = dict(unknown_top_level or {})
1601
- doc['v'] = LEDGER_VERSION
2573
+ doc['v'] = ledger_write_version(filters_by_name)
1602
2574
  doc['writer'] = LEDGER_WRITER
1603
2575
  doc['filters'] = filters_by_name
1604
2576
  return json.dumps(doc)
@@ -1688,13 +2660,16 @@ def list_requests(rpc_dir):
1688
2660
  return [name for _req_id, name in numbered]
1689
2661
 
1690
2662
 
1691
- def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
2663
+ def read_request(req_path, attempts=8, delay_s=0.02, max_delay_s=0.25, on_retry=None):
1692
2664
  """Read + parse a request file. Retries ONLY on OSError (e.g. a
1693
2665
  PermissionError from anti-virus or a file indexer holding a brief lock on
1694
2666
  a just-renamed file on Windows) -- a JSONDecodeError means the content
1695
2667
  itself is bad and is raised immediately; retrying that would just mask a
1696
2668
  genuinely malformed file rather than a transient lock.
1697
2669
 
2670
+ The wait doubles from `delay_s` up to `max_delay_s`, about 1s in all at the defaults, since a Windows
2671
+ file lock can outlast 100ms.
2672
+
1698
2673
  `on_retry(exc, attempt)`, if given, is called right before each sleep --
1699
2674
  the bridge logs the exception type through it; tests can assert on it
1700
2675
  without capturing stderr."""
@@ -1707,9 +2682,11 @@ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
1707
2682
  raise
1708
2683
  except OSError as e:
1709
2684
  last_err = e
2685
+ if attempt == attempts - 1:
2686
+ break
1710
2687
  if on_retry:
1711
2688
  on_retry(e, attempt)
1712
- time.sleep(delay_s)
2689
+ time.sleep(min(delay_s * (2 ** attempt), max_delay_s))
1713
2690
  raise last_err
1714
2691
 
1715
2692
 
@@ -1986,3 +2963,790 @@ def metadata_strip_settings(fmt, prop_names):
1986
2963
  if fmt == 'tiff' and 'save-geotiff' in names:
1987
2964
  settings.append('save-geotiff')
1988
2965
  return settings
2966
+
2967
+
2968
+ def profile_is_editors_own(texts):
2969
+ """Whether a color profile is the editor's built-in one, from its description, manufacturer,
2970
+ model and copyright strings (None for an image that has no profile of its own, i.e. the
2971
+ built-in one). Exports leave such a profile out: the file is then plain untagged sRGB, which
2972
+ every viewer assumes anyway, and the profile's name ("GIMP built-in sRGB") would otherwise ride
2973
+ along in every file. A camera's or phone's own profile is kept, unless the editor itself
2974
+ rewrote it (a precision or mode conversion marks the variant it makes with its own name);
2975
+ such a file is exported untagged too."""
2976
+ if texts is None:
2977
+ return True
2978
+ return any('gimp' in (t or '').lower() for t in texts)
2979
+
2980
+
2981
+ PSD_ICC_RESOURCE = 1039
2982
+
2983
+
2984
+ def psd_resources_without(section, resource_id):
2985
+ """A PSD image-resource section (the bytes after its 4-byte length) with every block of
2986
+ `resource_id` removed. Each block: '8BIM', a 2-byte id, a Pascal name padded to even length,
2987
+ a 4-byte size, then the data padded to even length. Raises ValueError on a malformed section."""
2988
+ out = bytearray()
2989
+ at = 0
2990
+ while at < len(section):
2991
+ start = at
2992
+ if section[at:at + 4] != b'8BIM' or at + 7 > len(section):
2993
+ raise ValueError('malformed PSD image resource at %d' % at)
2994
+ rid = int.from_bytes(section[at + 4:at + 6], 'big')
2995
+ at += 6
2996
+ at += 1 + section[at]
2997
+ at += at % 2
2998
+ if at + 4 > len(section):
2999
+ raise ValueError('malformed PSD image resource at %d' % start)
3000
+ size = int.from_bytes(section[at:at + 4], 'big')
3001
+ at += 4 + size + size % 2
3002
+ if at > len(section):
3003
+ raise ValueError('malformed PSD image resource at %d' % start)
3004
+ if rid != resource_id:
3005
+ out += section[start:at]
3006
+ return bytes(out)
3007
+
3008
+
3009
+ def layered_bake_plan(nodes):
3010
+ """Which layers a layered (.psd) export must flatten its live filters into first.
3011
+
3012
+ `nodes` is `(layer_id, parent_id, is_group, has_filters)` per layer in `_all_layers` order
3013
+ (a group before its children; `parent_id` None at top level). Returns `(merge_groups,
3014
+ bake_layers)`: a group carrying a live filter is merged whole into one raster layer, since the
3015
+ PSD exporter drops a group's own filter (verified live, GIMP 3.2.6) and merging is the only
3016
+ way to keep its look; everything inside it is absorbed by that merge. Any other layer with
3017
+ filters is baked in place, keeping its own mask, mode and opacity."""
3018
+ absorbed = set()
3019
+ merge_groups, bake_layers = [], []
3020
+ for layer_id, parent_id, is_group, has_filters in nodes:
3021
+ if parent_id is not None and parent_id in absorbed:
3022
+ absorbed.add(layer_id)
3023
+ elif is_group and has_filters:
3024
+ merge_groups.append(layer_id)
3025
+ absorbed.add(layer_id)
3026
+ elif has_filters:
3027
+ bake_layers.append(layer_id)
3028
+ return merge_groups, bake_layers
3029
+
3030
+
3031
+ # ---- text layers (gimp_text) -----------------------------------------------------------------
3032
+
3033
+ TEXT_MAX_CHARS = 2000
3034
+ TEXT_MIN_FONT_PT = 1
3035
+ TEXT_MAX_FONT_PT = 1296
3036
+ TEXT_DEFAULT_FONT_PT = 24
3037
+ TEXT_FONT_LIST_CAP = 200
3038
+ FONT_SUGGESTION_LIMIT = 8
3039
+ DEFAULT_TEXT_FONT = 'Sans-serif'
3040
+ DEFAULT_RESOLUTION_PPI = 72.0
3041
+
3042
+ # gimp_text's `alignment` enum is ps_text's. GIMP's own justification has only left / right /
3043
+ # center / fill, so the three "last line" variants Photoshop adds are refused rather than
3044
+ # approximated.
3045
+ TEXT_ALIGNMENTS = {'LEFT': 'left', 'CENTER': 'center', 'RIGHT': 'right', 'FULLYJUSTIFIED': 'fill'}
3046
+ TEXT_UNSUPPORTED_ALIGNMENTS = ('LEFTJUSTIFIED', 'CENTERJUSTIFIED', 'RIGHTJUSTIFIED')
3047
+ _JUSTIFICATION_TO_ALIGNMENT = {nick: name for name, nick in TEXT_ALIGNMENTS.items()}
3048
+
3049
+ # Words that describe a face of a family rather than the family itself, used to tell "Arial" +
3050
+ # "Narrow Bold" (a face of the family) from an unrelated font whose name merely starts the same.
3051
+ _FONT_STYLE_WORDS = frozenset((
3052
+ 'regular', 'book', 'roman', 'normal', 'medium', 'bold', 'italic', 'oblique', 'light', 'thin',
3053
+ 'extralight', 'ultralight', 'semilight', 'semibold', 'demibold', 'extrabold', 'ultrabold',
3054
+ 'black', 'heavy', 'condensed', 'semicondensed', 'extracondensed', 'narrow', 'cond', 'expanded',
3055
+ 'semiexpanded', 'extra', 'ultra', 'semi', 'demi', 'variable',
3056
+ ))
3057
+ _FONT_REGULAR_ORDER = ('regular', 'book', 'roman', 'normal', 'medium')
3058
+
3059
+
3060
+ def validate_text_content(value):
3061
+ if not isinstance(value, str):
3062
+ raise ValueError('text must be a string')
3063
+ if value == '':
3064
+ raise ValueError('text must not be empty')
3065
+ if len(value) > TEXT_MAX_CHARS:
3066
+ raise ValueError('text must be at most %d characters (got %d)' % (TEXT_MAX_CHARS, len(value)))
3067
+ return value
3068
+
3069
+
3070
+ def validate_font_size_pt(value):
3071
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
3072
+ raise ValueError('font_size must be a number')
3073
+ return validate_range('font_size', value, TEXT_MIN_FONT_PT, TEXT_MAX_FONT_PT)
3074
+
3075
+
3076
+ def validate_text_rgb(args):
3077
+ """(r, g, b) ints 0..255 from `red`/`green`/`blue`, or None when none of the three is given.
3078
+ Giving only some of them is refused rather than guessing the rest."""
3079
+ given = [k for k in ('red', 'green', 'blue') if args.get(k) is not None]
3080
+ if not given:
3081
+ return None
3082
+ if len(given) != 3:
3083
+ raise ValueError('red, green and blue must all be given together (missing: %s)'
3084
+ % ', '.join(k for k in ('red', 'green', 'blue') if k not in given))
3085
+ out = []
3086
+ for key in ('red', 'green', 'blue'):
3087
+ value = args[key]
3088
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or int(value) != value:
3089
+ raise ValueError('%s must be an integer 0..255' % key)
3090
+ out.append(validate_int_range(key, value, 0, 255))
3091
+ return tuple(out)
3092
+
3093
+
3094
+ def srgb_u8_to_linear(value):
3095
+ """An 8-bit sRGB channel as the linear-light float GEGL's set_rgba expects."""
3096
+ c = value / 255.0
3097
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
3098
+
3099
+
3100
+ def linear_to_srgb_u8(value):
3101
+ """A linear-light float channel (GEGL's get_rgba) as an 8-bit sRGB integer."""
3102
+ v = min(1.0, max(0.0, value))
3103
+ c = v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
3104
+ return int(round(c * 255))
3105
+
3106
+
3107
+ def justification_nick(alignment):
3108
+ """GIMP's justification nick ('left' | 'right' | 'center' | 'fill') for a ps_text-style
3109
+ `alignment`; refuses the variants GIMP has no equivalent for, naming what is supported."""
3110
+ if alignment in TEXT_UNSUPPORTED_ALIGNMENTS:
3111
+ raise ValueError(
3112
+ 'alignment %s is not supported by GIMP text layers; use one of %s'
3113
+ % (alignment, ', '.join(TEXT_ALIGNMENTS))
3114
+ )
3115
+ if alignment not in TEXT_ALIGNMENTS:
3116
+ raise ValueError('alignment must be one of %s'
3117
+ % ', '.join(list(TEXT_ALIGNMENTS) + list(TEXT_UNSUPPORTED_ALIGNMENTS)))
3118
+ return TEXT_ALIGNMENTS[alignment]
3119
+
3120
+
3121
+ def alignment_name(nick):
3122
+ """The ps_text-style alignment name for a GIMP justification nick (None when unknown)."""
3123
+ return _JUSTIFICATION_TO_ALIGNMENT.get(nick)
3124
+
3125
+
3126
+ def _resolution_ppi(ppi):
3127
+ return float(ppi) if ppi and ppi > 0 else DEFAULT_RESOLUTION_PPI
3128
+
3129
+
3130
+ def pt_to_px(pt, ppi):
3131
+ """Points -> pixels at the image's vertical resolution (1 pt = 1/72 in)."""
3132
+ return pt * _resolution_ppi(ppi) / 72.0
3133
+
3134
+
3135
+ def px_to_pt(px, ppi):
3136
+ return round(px * 72.0 / _resolution_ppi(ppi), 2)
3137
+
3138
+
3139
+ def unit_size_to_pt(size, unit_is_pixel, units_per_inch, ppi):
3140
+ """A text layer's font size in points, whichever unit it is stored in: pixels convert through
3141
+ the image resolution; any other unit through its own units-per-inch factor."""
3142
+ if unit_is_pixel or not units_per_inch:
3143
+ return px_to_pt(size, ppi)
3144
+ return round(size / units_per_inch * 72.0, 2)
3145
+
3146
+
3147
+ def _font_tokens(name):
3148
+ return re.sub(r'[-_]+', ' ', name).lower().split()
3149
+
3150
+
3151
+ def _font_key(name):
3152
+ return ' '.join(_font_tokens(name))
3153
+
3154
+
3155
+ def font_suggestions(query, names, limit=FONT_SUGGESTION_LIMIT):
3156
+ """Up to `limit` installed font names closest to `query`: names containing it (or contained
3157
+ by it) first, then the nearest by spelling."""
3158
+ import difflib
3159
+ q = _font_key(query)
3160
+ ordered = sorted(set(names), key=lambda n: n.lower())
3161
+ out = [n for n in ordered if q and (q in _font_key(n) or _font_key(n) in q)][:limit]
3162
+ if len(out) < limit:
3163
+ by_key = {}
3164
+ for n in ordered:
3165
+ by_key.setdefault(_font_key(n), n)
3166
+ for key in difflib.get_close_matches(q, list(by_key), n=limit, cutoff=0.0):
3167
+ if by_key[key] not in out:
3168
+ out.append(by_key[key])
3169
+ if len(out) >= limit:
3170
+ break
3171
+ return out[:limit]
3172
+
3173
+
3174
+ def resolve_font(query, names):
3175
+ """Resolve a family ("Inter", "Open Sans") or full name ("Inter Bold") to an installed font
3176
+ name, case- and spacing-insensitively. Returns (name, matched_by) with matched_by in
3177
+ 'name' | 'family+regular' | 'family'. Raises ValueError listing the closest installed names
3178
+ when nothing matches."""
3179
+ if not isinstance(query, str) or not query.strip():
3180
+ raise ValueError('font_name must be a non-empty string')
3181
+ ordered = sorted(set(names), key=lambda n: n.lower())
3182
+ q = _font_key(query)
3183
+ for n in ordered:
3184
+ if _font_key(n) == q:
3185
+ return n, 'name'
3186
+ q_sorted = sorted(q.split())
3187
+ for n in ordered:
3188
+ if sorted(_font_tokens(n)) == q_sorted:
3189
+ return n, 'name'
3190
+ for style in _FONT_REGULAR_ORDER:
3191
+ wanted = q + ' ' + style
3192
+ for n in ordered:
3193
+ if _font_key(n) == wanted:
3194
+ return n, 'family+regular'
3195
+ family = [
3196
+ n for n in ordered
3197
+ if _font_key(n).startswith(q + ' ')
3198
+ and all(t in _FONT_STYLE_WORDS for t in _font_key(n)[len(q) + 1:].split())
3199
+ ]
3200
+ if family:
3201
+ return min(family, key=lambda n: (len(n), n.lower())), 'family'
3202
+ suggestions = font_suggestions(query, ordered)
3203
+ if not suggestions:
3204
+ raise ValueError('no installed font matches %r (no fonts are installed)' % query[:100])
3205
+ raise ValueError(
3206
+ 'no installed font matches %r. Closest installed names: %s' % (query[:100], ', '.join(suggestions))
3207
+ )
3208
+
3209
+
3210
+ def pick_default_font(names):
3211
+ """The font a text layer gets when none is named: GIMP's built-in generic sans if listed,
3212
+ else the first installed name (None when there are none)."""
3213
+ ordered = sorted(set(names), key=lambda n: n.lower())
3214
+ for n in ordered:
3215
+ if _font_key(n) == _font_key(DEFAULT_TEXT_FONT):
3216
+ return n
3217
+ return ordered[0] if ordered else None
3218
+
3219
+
3220
+ def list_fonts(names, substring=None):
3221
+ """(page, total) for gimp_inspect what=fonts: names sorted case-insensitively, filtered by a
3222
+ case-insensitive substring, capped at TEXT_FONT_LIST_CAP; `total` counts every match."""
3223
+ ordered = sorted(set(names), key=lambda n: n.lower())
3224
+ if substring:
3225
+ needle = substring.lower()
3226
+ ordered = [n for n in ordered if needle in n.lower()]
3227
+ return ordered[:TEXT_FONT_LIST_CAP], len(ordered)
3228
+
3229
+
3230
+ # Headroom on the small-size layout probe: glyph advances hinted at a few pixels round to whole
3231
+ # pixels, so scaling a probe up can undershoot the full-size layout.
3232
+ TEXT_ESTIMATE_MARGIN = 1.3
3233
+
3234
+
3235
+ def estimate_text_extent(probe_w, probe_h, probe_px, size_px, chars=0, lines=1, letter_spacing=0.0,
3236
+ line_spacing=0.0, indent=0.0):
3237
+ """The (width, height) in px a text layer will render at `size_px`, from its layout measured
3238
+ at `probe_px` (`probe_w` x `probe_h`), with headroom. Letter spacing, line spacing and indent
3239
+ are absolute pixels that do not scale with the font, so each adds its full worst case: spacing
3240
+ after every character and between every line, plus the indent. Never below 1x1."""
3241
+ scale = size_px / float(probe_px)
3242
+ width = math.ceil(probe_w * scale * TEXT_ESTIMATE_MARGIN) + chars * max(0.0, letter_spacing) + max(0.0, indent)
3243
+ height = math.ceil(probe_h * scale * TEXT_ESTIMATE_MARGIN) + max(0, lines - 1) * max(0.0, line_spacing)
3244
+ return max(1, int(math.ceil(width))), max(1, int(math.ceil(height)))
3245
+
3246
+
3247
+ def check_estimated_text_size(width, height):
3248
+ """Refuses text whose estimated size (`estimate_text_extent`) is past the engine's size cap,
3249
+ before anything is drawn at full size."""
3250
+ try:
3251
+ validate_resize_dims(width, height)
3252
+ except ValueError as exc:
3253
+ raise ValueError(
3254
+ 'the text would render as about %dx%d px, past the size limit (%s); use a smaller '
3255
+ 'font_size or less text' % (width, height, exc)
3256
+ )
3257
+
3258
+
3259
+ # ---- text box and fit (gimp_text box / fit) --------------------------------------------------
3260
+
3261
+ TEXT_FIT_DEFAULT_MIN_PX = 8
3262
+ TEXT_FIT_MAX_MIN_PX = 10000
3263
+ TEXT_FIT_MAX_PROBES = 12
3264
+ TEXT_BOX_SCAN_MAX_PIXELS = 16_000_000
3265
+ TEXT_OVERFLOW_LINES = 1_000_000 # "more lines than any box holds": what an overflowing probe reports
3266
+ TEXT_LINE_PITCH_SAMPLE_LINES = 10
3267
+
3268
+ # A text box's own area ceiling, precision-aware like DOCUMENT_MEGAPIXEL_CAP but much tighter (a
3269
+ # text layer is a caption, not a canvas): 64 MP at 8-bit, halved for 16-bit and quartered for
3270
+ # 32-bit, and never above the matching document cap when EM_GIMP_MAX_MEGAPIXELS lowers those.
3271
+ TEXT_BOX_MEGAPIXEL_CAP = {
3272
+ '8': min(64, MAX_RESIZE_MEGAPIXELS),
3273
+ '16': min(32, MAX_RESIZE_MEGAPIXELS / 2),
3274
+ '32': min(16, MAX_RESIZE_MEGAPIXELS / 4),
3275
+ }
3276
+
3277
+ # Marks a text layer this tool put in a fixed box. GIMP cannot report a layer's box mode, so this
3278
+ # persistent layer parasite is how later edits know their size is bounded by the box.
3279
+ TEXT_BOX_TAG = 'editmamei-text-box'
3280
+
3281
+
3282
+ def check_text_box_area(width, height, precision='8'):
3283
+ """Refuses a text box past the precision-aware text-box area cap (`TEXT_BOX_MEGAPIXEL_CAP`)."""
3284
+ cap = TEXT_BOX_MEGAPIXEL_CAP[precision]
3285
+ megapixels = (width * height) / 1_000_000.0
3286
+ if megapixels > cap:
3287
+ raise ValueError(
3288
+ 'a %dx%d px text box is %.1f MP, past the %g MP limit for a %s-bit text layer; use a '
3289
+ 'smaller box' % (width, height, megapixels, cap, precision)
3290
+ )
3291
+
3292
+
3293
+ def text_scan_max_pixels(precision='8'):
3294
+ """The largest probe layer (px) a box scan renders: `TEXT_BOX_SCAN_MAX_PIXELS`, or the text-box
3295
+ area cap for `precision` when EM_GIMP_MAX_MEGAPIXELS has lowered it below that."""
3296
+ return min(TEXT_BOX_SCAN_MAX_PIXELS, int(TEXT_BOX_MEGAPIXEL_CAP[precision] * 1_000_000))
3297
+
3298
+
3299
+ def check_text_fit_box(width, height, precision='8'):
3300
+ """Refuses `fit` for a box too large to probe in one render (`text_scan_max_pixels`)."""
3301
+ limit = text_scan_max_pixels(precision)
3302
+ if width * height > limit:
3303
+ raise ValueError(
3304
+ 'fit supports a box of at most %g MP (got %dx%d px); use a smaller box, or set the '
3305
+ 'box and font_size without fit' % (limit / 1_000_000.0, width, height)
3306
+ )
3307
+
3308
+
3309
+ def text_scan_cap(width, precision='8'):
3310
+ """The tallest probe layer (px) the box scan renders at `width`."""
3311
+ return max(1, min(MAX_RESIZE_SIDE_PX, text_scan_max_pixels(precision) // max(1, width)))
3312
+
3313
+
3314
+ def fit_probe_plan(width, limit_h, pitch, precision='8'):
3315
+ """(scan_h, clamped): the probe height for testing whether text fits `limit_h`, a little
3316
+ taller than the limit so overflow shows as ink past it. `clamped` means the scan cap cut it
3317
+ below that height, so ink on its last row only says "reaches the end of the probe"."""
3318
+ ideal = int(math.ceil(limit_h + 2 * pitch)) + 1
3319
+ scan_h = min(text_scan_cap(width, precision), ideal)
3320
+ return scan_h, scan_h < ideal
3321
+
3322
+
3323
+ def fit_probe_overflows(ink_bottom_row, scan_h, clamped):
3324
+ """Whether a clamped probe (`fit_probe_plan`) with ink on its last row means the text
3325
+ overflows. An unclamped probe is judged by its line count instead."""
3326
+ return bool(clamped and ink_bottom_row >= scan_h - 1)
3327
+
3328
+
3329
+ def validate_text_box(box):
3330
+ """(width, height) from a `box` object `{width, height?}` in image px; height is None when
3331
+ omitted (the layer grows to fit the wrapped text). Each side is held to the engine's resize
3332
+ caps, and the area to its megapixel cap."""
3333
+ if not isinstance(box, dict):
3334
+ raise ValueError('box must be an object {width, height?}')
3335
+ unknown = sorted(set(box) - {'width', 'height'})
3336
+ if unknown:
3337
+ raise ValueError('box has unknown keys: %s' % ', '.join(unknown))
3338
+ if box.get('width') is None:
3339
+ raise ValueError('box.width is required')
3340
+ width = _box_side('box.width', box['width'])
3341
+ height = None if box.get('height') is None else _box_side('box.height', box['height'])
3342
+ validate_resize_dims(width, height if height is not None else 1)
3343
+ return width, height
3344
+
3345
+
3346
+ def _box_side(name, value):
3347
+ if (isinstance(value, bool) or not isinstance(value, (int, float))
3348
+ or (isinstance(value, float) and not math.isfinite(value)) or int(value) != value):
3349
+ raise ValueError('%s must be an integer' % name)
3350
+ return validate_int_range(name, value, 1, MAX_RESIZE_SIDE_PX)
3351
+
3352
+
3353
+ def validate_text_fit(args, box):
3354
+ """(fit, min_px) from `fit` / `min_size`. Fitting needs a box with both width and height;
3355
+ `min_size` (pixels) is only meaningful with `fit`."""
3356
+ fit = args.get('fit')
3357
+ if fit is not None and not isinstance(fit, bool):
3358
+ raise ValueError('fit must be a boolean')
3359
+ min_given = args.get('min_size') is not None
3360
+ if not fit:
3361
+ if min_given:
3362
+ raise ValueError('min_size only applies with fit: true')
3363
+ return False, None
3364
+ if box is None or box[1] is None:
3365
+ raise ValueError('fit needs a box with both width and height')
3366
+ min_px = TEXT_FIT_DEFAULT_MIN_PX
3367
+ if min_given:
3368
+ value = args['min_size']
3369
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
3370
+ raise ValueError('min_size must be a number')
3371
+ min_px = validate_range('min_size', value, 1, TEXT_FIT_MAX_MIN_PX)
3372
+ return True, min_px
3373
+
3374
+
3375
+ def fit_font_size(max_px, min_px, fits, max_probes=TEXT_FIT_MAX_PROBES):
3376
+ """The largest font size in [min_px, max_px] for which `fits(size)` is true, searched in
3377
+ whole pixels (the requested max is tried exactly as given). Returns (size, fitted, probes);
3378
+ `fitted` is False when even `min_px` overflows (the size is then `min_px`). Never calls
3379
+ `fits` more than `max_probes` times: the search keeps the best size that fit so far."""
3380
+ if min_px > max_px:
3381
+ min_px = max_px
3382
+ probes = 0
3383
+
3384
+ def probe(size):
3385
+ nonlocal probes
3386
+ probes += 1
3387
+ return fits(size)
3388
+
3389
+ if probe(max_px):
3390
+ return max_px, True, probes
3391
+ if min_px >= max_px or not probe(min_px):
3392
+ return min_px, False, probes
3393
+ lo, hi = min_px, max_px # lo fits, hi does not
3394
+ while probes < max_probes:
3395
+ mid = math.floor((lo + hi) / 2.0)
3396
+ if mid <= lo:
3397
+ break
3398
+ if probe(mid):
3399
+ lo = mid
3400
+ else:
3401
+ hi = mid
3402
+ return lo, True, probes
3403
+
3404
+
3405
+ def wrapped_line_count(ink_bottom_row, pitch, explicit_lines):
3406
+ """How many lines a wrapped text layout occupies, from the last row holding ink
3407
+ (`ink_bottom_row`, -1 when blank) and the line pitch (line height plus line spacing). The
3408
+ last line's ink always sits below the top of its own line box and within it, so its index is
3409
+ `floor(row / pitch)`; lines the content separates with newlines count even when blank."""
3410
+ lines = explicit_lines
3411
+ if ink_bottom_row >= 0 and pitch > 0:
3412
+ lines = max(lines, int(ink_bottom_row // pitch) + 1)
3413
+ return max(1, lines)
3414
+
3415
+
3416
+ TEXT_REPORT_MAX_CHARS = 200
3417
+
3418
+
3419
+ def text_for_report(text):
3420
+ """(text, length, truncated) for reporting a layer's text: at most TEXT_REPORT_MAX_CHARS,
3421
+ since a layer opened from a file can hold any amount."""
3422
+ text = text or ''
3423
+ return text[:TEXT_REPORT_MAX_CHARS], len(text), len(text) > TEXT_REPORT_MAX_CHARS
3424
+
3425
+
3426
+ def check_text_layer_size(width, height):
3427
+ """Refuses a rendered text layer past the engine's size cap (`validate_resize_dims`)."""
3428
+ try:
3429
+ validate_resize_dims(width, height)
3430
+ except ValueError as exc:
3431
+ raise ValueError(
3432
+ 'the text would render as a %dx%d px layer, past the size limit (%s); use a smaller '
3433
+ 'font_size or less text' % (width, height, exc)
3434
+ )
3435
+
3436
+
3437
+ # ---- retouch: heal / clone / spot heal --------------------------------------------------------
3438
+ #
3439
+ # Pure helpers (no gi). Coordinates are image pixels; `bounds` is the half-open box
3440
+ # (x0, y0, x1, y1) of the layer clipped to the canvas.
3441
+
3442
+ RETOUCH_TAG = 'editmamei-retouch'
3443
+ RETOUCH_METHODS = ('heal', 'clone', 'spot_heal')
3444
+ RETOUCH_TARGETS = ('retouch_layer', 'in_place')
3445
+ RETOUCH_MAX_POINTS = 500
3446
+ RETOUCH_MAX_SPOTS = 50
3447
+ RETOUCH_MIN_BRUSH = 1
3448
+ RETOUCH_MAX_BRUSH = 1000
3449
+ RETOUCH_DEFAULT_BRUSH = 20
3450
+ RETOUCH_DEFAULT_HARDNESS = {'heal': 0.5, 'clone': 0.8}
3451
+ RETOUCH_SPOT_HARDNESS = 0.5
3452
+ RETOUCH_MAX_SPOT_RADIUS = 500
3453
+ # Spot-heal source search: 8 directions on two rings, as multiples of the spot radius.
3454
+ RETOUCH_SPOT_RINGS = (2.2, 3.5)
3455
+ RETOUCH_SPOT_DIRECTIONS = 8
3456
+ # Healing blends a dab to the pixels around its edge, so the dab must reach past the spot: 2.6x the
3457
+ # radius is 1.3x the spot's diameter. A smaller brush sits wholly inside the spot and keeps its color.
3458
+ RETOUCH_SPOT_BRUSH_FACTOR = 2.6
3459
+ RETOUCH_SPOT_MIN_BRUSH = 3.0
3460
+ # The annulus a spot's surround is measured over, as a multiple of the spot radius.
3461
+ RETOUCH_SPOT_RING_OUTER = 1.6
3462
+ # How far a spot's heal reaches, as a multiple of its radius: half the spot brush (1.3x) beyond the
3463
+ # spot's own edge. Source candidates must keep this footprint inside the layer, and the Retouch
3464
+ # layer's mask reveals a feathered disc of this radius around each spot.
3465
+ RETOUCH_SPOT_REACH = 2.3
3466
+ # Hardness of the dab that reveals a healed spot on the Retouch layer's mask.
3467
+ RETOUCH_REVEAL_HARDNESS = 0.7
3468
+ # Work budgets, measured on a 6000x4000 image so the worst accepted call stays under about a minute:
3469
+ # a heal stroke costs about (path length + one brush) x brush, at worst ~25 us per unit (brush 90-100;
3470
+ # ~2-9 us at other sizes; a clone is far cheaper), so 2,000,000 is ~50 s; spot_heal costs ~270 us per
3471
+ # unit of the sum of radius squared, so 200,000 is ~55 s. Mirrored in tools/gimp-retouch-tools.ts.
3472
+ RETOUCH_MAX_STROKE_WORK = 2000000
3473
+ RETOUCH_MAX_SPOT_WORK = 200000
3474
+
3475
+
3476
+ def _finite_number(name, value):
3477
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
3478
+ raise ValueError('%s must be a finite number, got %r' % (name, value))
3479
+ return float(value)
3480
+
3481
+
3482
+ def _xy(name, value):
3483
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
3484
+ raise ValueError('%s must be [x, y], got %r' % (name, value))
3485
+ return [_finite_number(name, value[0]), _finite_number(name, value[1])]
3486
+
3487
+
3488
+ def validate_retouch_args(args):
3489
+ """The validated, defaulted `retouch` options as a dict. Raises ValueError (-> invalid_argument)
3490
+ naming the field. heal/clone need `points` plus exactly one of `source` / `source_offset`
3491
+ (returned as `source_offset` (dx, dy) relative to points[0]); spot_heal needs `spots`."""
3492
+ method = args.get('method')
3493
+ if method is None:
3494
+ raise ValueError('method is required')
3495
+ validate_choice('method', method, RETOUCH_METHODS)
3496
+ target = validate_choice('target', args.get('target') or 'retouch_layer', RETOUCH_TARGETS)
3497
+ opts = {'method': method, 'target': target}
3498
+ if method == 'spot_heal':
3499
+ if args.get('hardness') is not None:
3500
+ raise ValueError('hardness does not apply to method spot_heal (it sizes its own brush)')
3501
+ spots = args.get('spots')
3502
+ if not isinstance(spots, list) or not 1 <= len(spots) <= RETOUCH_MAX_SPOTS:
3503
+ raise ValueError('spots must be a list of 1..%d spots' % RETOUCH_MAX_SPOTS)
3504
+ clean = []
3505
+ for i, spot in enumerate(spots):
3506
+ if not isinstance(spot, dict):
3507
+ raise ValueError('spots[%d] must be {x, y, radius}' % i)
3508
+ radius = _finite_number('spots[%d].radius' % i, spot.get('radius'))
3509
+ if not 1 <= radius <= RETOUCH_MAX_SPOT_RADIUS:
3510
+ raise ValueError('spots[%d].radius must be within 1..%d' % (i, RETOUCH_MAX_SPOT_RADIUS))
3511
+ clean.append((
3512
+ _finite_number('spots[%d].x' % i, spot.get('x')),
3513
+ _finite_number('spots[%d].y' % i, spot.get('y')),
3514
+ radius,
3515
+ ))
3516
+ work = retouch_spots_work(clean)
3517
+ if work > RETOUCH_MAX_SPOT_WORK:
3518
+ raise ValueError(
3519
+ 'these spots are too large to heal in one call (the sum of radius squared is %g, '
3520
+ 'the limit is %d): split them into several calls or use smaller radii'
3521
+ % (work, RETOUCH_MAX_SPOT_WORK)
3522
+ )
3523
+ opts['spots'] = clean
3524
+ return opts
3525
+ points = args.get('points')
3526
+ if not isinstance(points, list) or not 1 <= len(points) <= RETOUCH_MAX_POINTS:
3527
+ raise ValueError('points must be a list of 1..%d [x, y] points' % RETOUCH_MAX_POINTS)
3528
+ opts['points'] = [_xy('points', p) for p in points]
3529
+ source, offset = args.get('source'), args.get('source_offset')
3530
+ if (source is None) == (offset is None):
3531
+ raise ValueError('give exactly one of source {x, y} or source_offset {dx, dy}')
3532
+ if source is not None:
3533
+ if not isinstance(source, dict):
3534
+ raise ValueError('source must be {x, y}')
3535
+ sx = _finite_number('source.x', source.get('x'))
3536
+ sy = _finite_number('source.y', source.get('y'))
3537
+ opts['source_offset'] = (sx - opts['points'][0][0], sy - opts['points'][0][1])
3538
+ else:
3539
+ if not isinstance(offset, dict):
3540
+ raise ValueError('source_offset must be {dx, dy}')
3541
+ opts['source_offset'] = (
3542
+ _finite_number('source_offset.dx', offset.get('dx')),
3543
+ _finite_number('source_offset.dy', offset.get('dy')),
3544
+ )
3545
+ brush = args.get('brush_size')
3546
+ opts['brush_size'] = validate_range(
3547
+ 'brush_size', RETOUCH_DEFAULT_BRUSH if brush is None else brush,
3548
+ RETOUCH_MIN_BRUSH, RETOUCH_MAX_BRUSH,
3549
+ )
3550
+ work = retouch_stroke_work(opts['points'], opts['brush_size'])
3551
+ if work > RETOUCH_MAX_STROKE_WORK:
3552
+ raise ValueError(
3553
+ 'this stroke is too long for its brush size to paint in one call (about %d px of '
3554
+ 'path x brush, the limit is %d): split the stroke into several calls or use a '
3555
+ 'smaller brush' % (work, RETOUCH_MAX_STROKE_WORK)
3556
+ )
3557
+ hardness = args.get('hardness')
3558
+ opts['hardness'] = validate_range(
3559
+ 'hardness', RETOUCH_DEFAULT_HARDNESS[method] if hardness is None else hardness, 0.0, 1.0
3560
+ )
3561
+ return opts
3562
+
3563
+
3564
+ def retouch_stroke_work(points, brush_size):
3565
+ """The cost estimate of a heal/clone stroke: (path length + one brush) x brush, about the area
3566
+ the brush sweeps (a single dab still costs its own area)."""
3567
+ length = sum(
3568
+ math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(points, points[1:])
3569
+ )
3570
+ return (length + brush_size) * brush_size
3571
+
3572
+
3573
+ def retouch_spots_work(spots):
3574
+ """The cost estimate of a spot_heal call: the sum of radius squared over its (x, y, r) spots."""
3575
+ return sum(r * r for _x, _y, r in spots)
3576
+
3577
+
3578
+ def retouch_bounds(layer_x, layer_y, layer_width, layer_height, canvas_width, canvas_height):
3579
+ """The layer's box clipped to the canvas as half-open (x0, y0, x1, y1); None when empty."""
3580
+ x0, y0 = max(0, layer_x), max(0, layer_y)
3581
+ x1 = min(canvas_width, layer_x + layer_width)
3582
+ y1 = min(canvas_height, layer_y + layer_height)
3583
+ if x1 <= x0 or y1 <= y0:
3584
+ return None
3585
+ return (x0, y0, x1, y1)
3586
+
3587
+
3588
+ def retouch_check_points(points, bounds, what):
3589
+ """Raises ValueError naming `bounds` for the first point outside the half-open box."""
3590
+ x0, y0, x1, y1 = bounds
3591
+ for i, (x, y) in enumerate(points):
3592
+ if not (x0 <= x < x1 and y0 <= y < y1):
3593
+ raise ValueError(
3594
+ '%s point %d (%g, %g) is outside the layer bounds x %d..%d, y %d..%d (image pixels)'
3595
+ % (what, i, x, y, x0, x1 - 1, y0, y1 - 1)
3596
+ )
3597
+
3598
+
3599
+ def retouch_spot_brush(radius):
3600
+ """Brush diameter for healing a spot of `radius`: 1.3x the spot's diameter, within 3..1000."""
3601
+ return max(RETOUCH_SPOT_MIN_BRUSH,
3602
+ min(float(RETOUCH_MAX_BRUSH), radius * RETOUCH_SPOT_BRUSH_FACTOR))
3603
+
3604
+
3605
+ def retouch_cover_stroke(cx, cy, radius, brush_size, bounds=None, max_points=RETOUCH_MAX_POINTS):
3606
+ """A stroke (list of [x, y]) whose brush (diameter `brush_size`) covers the disc at (cx, cy):
3607
+ an Archimedean spiral from the center out to `radius` with a turn pitch of 0.6 x the brush, then
3608
+ one full circle at `radius`, points spaced 0.4 x the brush apart (a heal dab blends to its
3609
+ own edge, so several overlapping dabs beat one). Clamped into `bounds`; the
3610
+ spacing widens if the stroke would exceed `max_points`."""
3611
+ if radius <= 0 or brush_size <= 0:
3612
+ return [[cx, cy]]
3613
+ scale = 1.0
3614
+ # Estimated point count (spiral length / spacing + the closing circle), so a stroke that would
3615
+ # blow the cap widens its spacing up front instead of generating it first.
3616
+ while ((math.pi * radius ** 2 / (0.6 * brush_size * scale) + 2 * math.pi * radius)
3617
+ / (0.4 * brush_size * scale) > max_points * 0.9):
3618
+ scale *= 1.1
3619
+ while True:
3620
+ pitch, step = 0.6 * brush_size * scale, 0.4 * brush_size * scale
3621
+ pts = [(cx, cy)]
3622
+ theta = 0.0
3623
+ while True:
3624
+ r = min(radius, pitch * theta / (2 * math.pi))
3625
+ if r >= radius:
3626
+ break
3627
+ theta += step / max(r, step)
3628
+ r = min(radius, pitch * theta / (2 * math.pi))
3629
+ pts.append((cx + r * math.cos(theta), cy + r * math.sin(theta)))
3630
+ if r >= radius:
3631
+ break
3632
+ n = max(3, int(math.ceil(2 * math.pi * radius / step)))
3633
+ for k in range(1, n + 1):
3634
+ a = theta + 2 * math.pi * k / n
3635
+ pts.append((cx + radius * math.cos(a), cy + radius * math.sin(a)))
3636
+ if len(pts) <= max_points:
3637
+ break
3638
+ scale *= 1.25
3639
+ if bounds is not None:
3640
+ x0, y0, x1, y1 = bounds
3641
+ pts = [(min(max(x, x0), x1 - 1), min(max(y, y0), y1 - 1)) for x, y in pts]
3642
+ return [[round(x, 2), round(y, 2)] for x, y in pts]
3643
+
3644
+
3645
+ def retouch_painted_bbox(points, brush_size, bounds):
3646
+ """[x, y, w, h] of the stroke's brush footprint (points grown by half the brush), clipped to
3647
+ `bounds`; [0, 0, 0, 0] when nothing is left."""
3648
+ half = brush_size / 2.0
3649
+ x0 = max(bounds[0], int(math.floor(min(p[0] for p in points) - half)))
3650
+ y0 = max(bounds[1], int(math.floor(min(p[1] for p in points) - half)))
3651
+ x1 = min(bounds[2], int(math.ceil(max(p[0] for p in points) + half)))
3652
+ y1 = min(bounds[3], int(math.ceil(max(p[1] for p in points) + half)))
3653
+ if x1 <= x0 or y1 <= y0:
3654
+ return [0, 0, 0, 0]
3655
+ return [x0, y0, x1 - x0, y1 - y0]
3656
+
3657
+
3658
+ def union_bbox_xywh(a, b):
3659
+ """Union of two [x, y, w, h] boxes; an empty (zero-area) box is ignored."""
3660
+ if a is None or a[2] <= 0 or a[3] <= 0:
3661
+ return list(b) if b is not None else None
3662
+ if b is None or b[2] <= 0 or b[3] <= 0:
3663
+ return list(a)
3664
+ x0, y0 = min(a[0], b[0]), min(a[1], b[1])
3665
+ x1, y1 = max(a[0] + a[2], b[0] + b[2]), max(a[1] + a[3], b[1] + b[3])
3666
+ return [x0, y0, x1 - x0, y1 - y0]
3667
+
3668
+
3669
+ def spot_candidates(cx, cy, radius, bounds, reach=None):
3670
+ """Candidate source offsets (dx, dy) for a spot: 8 directions on each of the two rings
3671
+ (2.2x and 3.5x the radius). A candidate is dropped when its source footprint -- a disc of
3672
+ `reach` (default 2.3 x radius, RETOUCH_SPOT_REACH: the spot plus the healing brush's edge)
3673
+ around the shifted center -- leaves `bounds`. Rounded to whole pixels; order is ring-major, then by angle."""
3674
+ if reach is None:
3675
+ reach = radius * RETOUCH_SPOT_REACH
3676
+ x0, y0, x1, y1 = bounds
3677
+ out = []
3678
+ for ring in RETOUCH_SPOT_RINGS:
3679
+ dist = ring * radius
3680
+ for k in range(RETOUCH_SPOT_DIRECTIONS):
3681
+ a = 2 * math.pi * k / RETOUCH_SPOT_DIRECTIONS
3682
+ dx, dy = int(round(dist * math.cos(a))), int(round(dist * math.sin(a)))
3683
+ sx, sy = cx + dx, cy + dy
3684
+ if sx - reach < x0 or sy - reach < y0 or sx + reach > x1 or sy + reach > y1:
3685
+ continue
3686
+ out.append((dx, dy))
3687
+ return out
3688
+
3689
+
3690
+ def spot_score(cand_mean, cand_std, ring_mean, ring_std):
3691
+ """How far a candidate disc's (mean, std) is from the spot's surround ring: the sum of the two
3692
+ absolute differences, in histogram units (0-255). Lower is better."""
3693
+ return abs(cand_mean - ring_mean) + abs(cand_std - ring_std)
3694
+
3695
+
3696
+ def spot_pick_best(scored):
3697
+ """The (dx, dy, score) with the lowest score from [(dx, dy, score), ...]; the earliest wins
3698
+ a tie. None when empty."""
3699
+ best = None
3700
+ for item in scored:
3701
+ if best is None or item[2] < best[2]:
3702
+ best = item
3703
+ return best
3704
+
3705
+
3706
+ # ---- render_region / keep_alpha ---------------------------------------------------------------
3707
+
3708
+ # render_region is never downscaled: a box past either bound is refused outright.
3709
+ RENDER_REGION_MAX_PIXELS = 16_000_000
3710
+ RENDER_REGION_MAX_SIDE = 4096
3711
+ KEEP_ALPHA_EXTENSIONS = ('.png', '.webp')
3712
+
3713
+
3714
+ def _whole_pixels(name, value):
3715
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
3716
+ raise ValueError('%s must be a whole number of pixels, got %r' % (name, value))
3717
+ if value != int(value):
3718
+ raise ValueError('%s must be a whole number of pixels, got %r' % (name, value))
3719
+ return int(value)
3720
+
3721
+
3722
+ def validate_render_box(box, canvas_width, canvas_height):
3723
+ """`box` [x, y, w, h] in image pixels, clamped to the canvas, as ints. Refuses a malformed box,
3724
+ a box with no pixels on the canvas, and one past RENDER_REGION_MAX_PIXELS /
3725
+ RENDER_REGION_MAX_SIDE (checked after clamping)."""
3726
+ if not isinstance(box, (list, tuple)) or len(box) != 4:
3727
+ raise ValueError('box must be [x, y, width, height] in image pixels')
3728
+ x, y, w, h = [_whole_pixels('box', v) for v in box]
3729
+ if w <= 0 or h <= 0:
3730
+ raise ValueError('box width and height must be positive')
3731
+ x0, y0 = max(0, x), max(0, y)
3732
+ x1, y1 = min(canvas_width, x + w), min(canvas_height, y + h)
3733
+ if x1 <= x0 or y1 <= y0:
3734
+ raise ValueError(
3735
+ 'box [%d, %d, %d, %d] does not overlap the %dx%d canvas' % (x, y, w, h, canvas_width, canvas_height)
3736
+ )
3737
+ w, h = x1 - x0, y1 - y0
3738
+ if max(w, h) > RENDER_REGION_MAX_SIDE or w * h > RENDER_REGION_MAX_PIXELS:
3739
+ raise ValueError(
3740
+ 'box is %dx%d px; render_region allows at most %d px on a side and %d megapixels '
3741
+ '(it never downscales)' % (w, h, RENDER_REGION_MAX_SIDE, RENDER_REGION_MAX_PIXELS // 1_000_000)
3742
+ )
3743
+ return x0, y0, w, h
3744
+
3745
+
3746
+ def validate_keep_alpha(args, path):
3747
+ """Whether `export` should keep transparency: `keep_alpha` must be a boolean (default False)
3748
+ and only PNG and WebP can carry it."""
3749
+ keep = optional_bool(args, 'keep_alpha', False)
3750
+ if keep and os.path.splitext(path)[1].lower() not in KEEP_ALPHA_EXTENSIONS:
3751
+ raise ValueError('keep_alpha only works with a .png or .webp file path')
3752
+ return keep