editmamei 1.8.0 → 1.9.1

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.
@@ -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,12 +352,19 @@ 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
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
184
368
  _DEFAULT_MAX_RESIZE_MEGAPIXELS = 250
185
369
 
186
370
 
@@ -204,6 +388,17 @@ MAX_RESIZE_MEGAPIXELS = megapixel_cap_from_env(os.environ.get('EM_GIMP_MAX_MEGAP
204
388
 
205
389
  MAX_FEATHER_PX = 1000
206
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
+
207
402
  # gimp_modify_selection's expand/contract/border radius cap -- much lower than MAX_FEATHER_PX.
208
403
  # `Gimp.Selection.grow`/`shrink`/`border` are morphological (structuring-element) operations whose
209
404
  # cost scales with the radius, unlike `feather` (a GEGL blur, flat ~0.5s regardless of radius on a
@@ -331,6 +526,24 @@ def require(args, name):
331
526
  return args[name]
332
527
 
333
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
+
334
547
  def require_bool(args, name):
335
548
  """Like `require`, but also rejects anything that isn't a real JSON boolean -- `bool("false")`
336
549
  is `True` in Python (any non-empty string is truthy), so a caller that sends the STRING
@@ -916,10 +1129,18 @@ def build_saturation_params(args, defaults):
916
1129
  }
917
1130
 
918
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
+
919
1139
  def build_vibrance_params(args, defaults):
920
1140
  return {
921
1141
  'vibrance': resolve_field(
922
- 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,
923
1144
  ),
924
1145
  'saturation': resolve_field(
925
1146
  args, 'saturation', defaults, 'saturation', lambda v: validate_range('saturation', v, 0.0, 10.0)
@@ -1033,13 +1254,35 @@ EFFECT_OPERATIONS = {
1033
1254
  'lens_blur': 'gegl:focus-blur',
1034
1255
  'add_noise': 'gegl:noise-rgb',
1035
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',
1036
1262
  }
1037
1263
 
1038
1264
  # The inverse of ADJUST_OPERATIONS and EFFECT_OPERATIONS together, for a ledger record written
1039
1265
  # without a `type` field -- one shared map, since `op_list_filters` looks a record's operation up
1040
- # 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).
1041
1271
  OPERATION_TYPES = {operation: type_ for type_, operation in ADJUST_OPERATIONS.items()}
1042
- 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'))
1043
1286
 
1044
1287
  # Every validate_range/validate_int_range call below uses `val`, not `v`, as its lambda's
1045
1288
  # parameter name -- deliberately, so it is NOT matched by gimp-adjustment-tools.test.ts's own
@@ -1194,6 +1437,79 @@ def build_drop_shadow_params(args, defaults):
1194
1437
  }
1195
1438
 
1196
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
+
1197
1513
  EFFECT_PARAM_BUILDERS = {
1198
1514
  'vignette': build_vignette_params,
1199
1515
  'black_white': build_black_white_params,
@@ -1201,6 +1517,9 @@ EFFECT_PARAM_BUILDERS = {
1201
1517
  'lens_blur': build_lens_blur_params,
1202
1518
  'add_noise': build_add_noise_params,
1203
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,
1204
1523
  }
1205
1524
 
1206
1525
  # Creation-time defaults, already in GEGL-property units -- probed live via `describe_operation`
@@ -1214,6 +1533,9 @@ EFFECT_CREATE_DEFAULTS = {
1214
1533
  'lens_blur': {'blur-radius': 25.0, 'highlight-factor': 0.0},
1215
1534
  'add_noise': {'red': 0.2, 'green': 0.2, 'blue': 0.2, 'alpha': 0.0, 'seed': 0},
1216
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},
1217
1539
  }
1218
1540
 
1219
1541
 
@@ -1270,7 +1592,7 @@ USER_FIELDS = {
1270
1592
  ),
1271
1593
  'saturation': (('scale', 'scale', _same),),
1272
1594
  'vibrance': (
1273
- ('vibrance', 'vibrance', _same),
1595
+ ('vibrance', 'vibrance', _scaled(VIBRANCE_GEGL_DIVISOR)),
1274
1596
  ('saturation', 'saturation', _same),
1275
1597
  ),
1276
1598
  'sharpen': (
@@ -1313,6 +1635,20 @@ USER_FIELDS = {
1313
1635
  ('radius', 'radius', _same),
1314
1636
  ('opacity', 'opacity', _same),
1315
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
+ ),
1316
1652
  }
1317
1653
 
1318
1654
  CURVES_USER_FIELDS = ('channel', 'points')
@@ -1399,7 +1735,6 @@ def json_safe(value):
1399
1735
  # operations this engine actually uses.
1400
1736
  ALLOWED_DESCRIBE_OPERATIONS = frozenset(ADJUST_OPERATIONS.values()) | frozenset(EFFECT_OPERATIONS.values())
1401
1737
 
1402
-
1403
1738
  # ---- geometry-op masked-filter detection (pure logic; ops.py supplies the live GIMP state) -----
1404
1739
 
1405
1740
 
@@ -1632,7 +1967,7 @@ def flip_effect_params(operation, params, orientation):
1632
1967
  else:
1633
1968
  params['angle'] = _round_transform_value(_wrap_angle_deg(-params['angle']))
1634
1969
  return params
1635
- if operation == 'gegl:dropshadow':
1970
+ if operation == 'gegl:dropshadow' and 'x' in params: # outline / outer_glow have no offset
1636
1971
  params = dict(params)
1637
1972
  if orientation == 'horizontal':
1638
1973
  params['x'] = _round_transform_value(-params['x'])
@@ -1669,7 +2004,7 @@ def rotate_effect_params(operation, params, degrees, layer_width, layer_height):
1669
2004
  canonical_degrees = _right_angle_k(degrees) * 90.0
1670
2005
  params['angle'] = _round_transform_value(_wrap_angle_deg(params['angle'] + canonical_degrees))
1671
2006
  return params
1672
- if operation == 'gegl:dropshadow':
2007
+ if operation == 'gegl:dropshadow' and 'x' in params:
1673
2008
  params = dict(params)
1674
2009
  cos_t, sin_t = _right_angle_cos_sin(degrees)
1675
2010
  dx, dy = params['x'], params['y']
@@ -1719,9 +2054,18 @@ def resize_effect_params(operation, params, scale_x, scale_y):
1719
2054
  if operation == 'gegl:dropshadow':
1720
2055
  params = dict(params)
1721
2056
  isotropic_scale = math.sqrt(scale_x * scale_y)
1722
- params['x'] = _round_transform_value(params['x'] * scale_x)
1723
- params['y'] = _round_transform_value(params['y'] * scale_y)
1724
- 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)
1725
2069
  return params
1726
2070
  return params
1727
2071
 
@@ -1735,7 +2079,12 @@ EFFECT_TRANSFORM_BOUNDS = {
1735
2079
  'gegl:vignette': {'x': (0.0, 1.0), 'y': (0.0, 1.0)},
1736
2080
  'gegl:motion-blur-linear': {'length': (0.0, 1000.0), 'angle': (-180.0, 180.0)},
1737
2081
  'gegl:focus-blur': {'blur-radius': (0.0, 150.0)},
1738
- '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)},
1739
2088
  }
1740
2089
 
1741
2090
  # The user-facing (gimp_add_effect) field name for each EFFECT_TRANSFORM_BOUNDS entry -- so a
@@ -1752,10 +2101,44 @@ EFFECT_TRANSFORM_FIELD_NAMES = {
1752
2101
  ('gegl:dropshadow', 'x'): 'offset_x',
1753
2102
  ('gegl:dropshadow', 'y'): 'offset_y',
1754
2103
  ('gegl:dropshadow', 'radius'): 'radius',
2104
+ ('gegl:dropshadow', 'grow-radius'): 'width/spread',
2105
+ ('gegl:bilateral-filter', 'blur-radius'): 'radius',
2106
+ }
2107
+
2108
+
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,
1755
2118
  }
1756
2119
 
1757
2120
 
1758
- def validate_effect_transform(op_name, operation, filter_name, new_params):
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):
1759
2142
  """Refuse `op_name` (the geometry op about to run) outright if any of `new_params` -- already
1760
2143
  computed for `operation` by flip_effect_params/rotate_effect_params/resize_effect_params --
1761
2144
  would leave the range its own create/re-edit path enforces. Called BEFORE the geometry
@@ -1763,10 +2146,12 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
1763
2146
  range never gets a chance to silently clamp via GObject's own property setter instead of a
1764
2147
  clear, actionable refusal -- and the geometry op never partially applies. Names the filter
1765
2148
  (`filter_name`, the same way `_refuse_if_masked_filters` does) and the field in gimp_add_
1766
- 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."""
1767
2151
  for prop, (lo, hi) in EFFECT_TRANSFORM_BOUNDS.get(operation, {}).items():
1768
2152
  if prop not in new_params:
1769
2153
  continue
2154
+ hi = min(hi, EFFECT_TRANSFORM_CEILINGS.get((type_, prop), hi))
1770
2155
  value = new_params[prop]
1771
2156
  if not lo <= value <= hi:
1772
2157
  field = EFFECT_TRANSFORM_FIELD_NAMES.get((operation, prop), prop)
@@ -2086,10 +2471,10 @@ def parse_ledger(raw):
2086
2471
  parasite bytes ARE the filters map directly (no wrapper). Treated as
2087
2472
  v1-compatible so files written before the ledger was versioned keep
2088
2473
  working.
2089
- - Versioned + "v" == LEDGER_VERSION: "filters" is the map; any OTHER
2474
+ - Versioned + "v" in 1..LEDGER_VERSION: "filters" is the map; any OTHER
2090
2475
  top-level key is returned as `unknown_top_level` so a future writer's
2091
2476
  extra fields round-trip untouched through serialize_ledger.
2092
- - Versioned + "v" != LEDGER_VERSION (in practice: greater, from a future
2477
+ - Versioned + "v" outside that range (in practice: greater, from a future
2093
2478
  writer): every filter is treated as absent — the caller falls back to
2094
2479
  readback for all of them, per the documented contract that we never
2095
2480
  guess at a schema we don't recognize. `unknown_top_level` still
@@ -2125,7 +2510,7 @@ def parse_ledger(raw):
2125
2510
  return _validate_filters(raw_filters), {}, raw_filters
2126
2511
  unknown = {k: v for k, v in doc.items() if k not in ('v', 'writer', 'filters')}
2127
2512
  raw_filters = dict(doc['filters'])
2128
- if doc['v'] != LEDGER_VERSION:
2513
+ if not 1 <= doc['v'] <= LEDGER_VERSION:
2129
2514
  return {}, unknown, raw_filters
2130
2515
  return _validate_filters(raw_filters), unknown, raw_filters
2131
2516
 
@@ -2173,12 +2558,19 @@ def ledger_is_newer_version(raw):
2173
2558
  return doc['v'] > LEDGER_VERSION
2174
2559
 
2175
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
+
2176
2568
  def serialize_ledger(filters_by_name, unknown_top_level=None):
2177
- """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
2178
2570
  unknown_top_level keys carried over from parse_ledger byte-for-byte
2179
2571
  (as JSON values -- not literal bytes, but nothing under them is rewritten)."""
2180
2572
  doc = dict(unknown_top_level or {})
2181
- doc['v'] = LEDGER_VERSION
2573
+ doc['v'] = ledger_write_version(filters_by_name)
2182
2574
  doc['writer'] = LEDGER_WRITER
2183
2575
  doc['filters'] = filters_by_name
2184
2576
  return json.dumps(doc)
@@ -2268,13 +2660,16 @@ def list_requests(rpc_dir):
2268
2660
  return [name for _req_id, name in numbered]
2269
2661
 
2270
2662
 
2271
- 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):
2272
2664
  """Read + parse a request file. Retries ONLY on OSError (e.g. a
2273
2665
  PermissionError from anti-virus or a file indexer holding a brief lock on
2274
2666
  a just-renamed file on Windows) -- a JSONDecodeError means the content
2275
2667
  itself is bad and is raised immediately; retrying that would just mask a
2276
2668
  genuinely malformed file rather than a transient lock.
2277
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
+
2278
2673
  `on_retry(exc, attempt)`, if given, is called right before each sleep --
2279
2674
  the bridge logs the exception type through it; tests can assert on it
2280
2675
  without capturing stderr."""
@@ -2287,9 +2682,11 @@ def read_request(req_path, attempts=5, delay_s=0.02, on_retry=None):
2287
2682
  raise
2288
2683
  except OSError as e:
2289
2684
  last_err = e
2685
+ if attempt == attempts - 1:
2686
+ break
2290
2687
  if on_retry:
2291
2688
  on_retry(e, attempt)
2292
- time.sleep(delay_s)
2689
+ time.sleep(min(delay_s * (2 ** attempt), max_delay_s))
2293
2690
  raise last_err
2294
2691
 
2295
2692
 
@@ -2568,6 +2965,69 @@ def metadata_strip_settings(fmt, prop_names):
2568
2965
  return settings
2569
2966
 
2570
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
+
2571
3031
  # ---- text layers (gimp_text) -----------------------------------------------------------------
2572
3032
 
2573
3033
  TEXT_MAX_CHARS = 2000
@@ -2796,6 +3256,163 @@ def check_estimated_text_size(width, height):
2796
3256
  )
2797
3257
 
2798
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
+
2799
3416
  TEXT_REPORT_MAX_CHARS = 200
2800
3417
 
2801
3418
 
@@ -2815,3 +3432,321 @@ def check_text_layer_size(width, height):
2815
3432
  'the text would render as a %dx%d px layer, past the size limit (%s); use a smaller '
2816
3433
  'font_size or less text' % (width, height, exc)
2817
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