editmamei 1.7.0 → 1.8.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 (44) hide show
  1. package/README.md +5 -4
  2. package/dist/backends/gimp/bridge/lib.py +833 -4
  3. package/dist/backends/gimp/bridge/ops.py +1943 -110
  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/cli/help.js +2 -2
  9. package/dist/cli/install.js +2 -2
  10. package/dist/core/settings.js +7 -0
  11. package/dist/core/tool-activity.js +13 -1
  12. package/dist/core/tool-groups.js +7 -1
  13. package/dist/core/tool-tiers.js +7 -1
  14. package/dist/install-channel.js +2 -0
  15. package/dist/modules/gimp/index.js +8 -2
  16. package/dist/skills/editmamei-skill.zip +0 -0
  17. package/dist/telemetry/client.js +23 -16
  18. package/dist/telemetry/outbox.js +248 -28
  19. package/dist/telemetry/sanitize.js +15 -1
  20. package/dist/tools/adjustment-tools.js +9 -1
  21. package/dist/tools/document-tools.js +50 -9
  22. package/dist/tools/filter-tools.js +2 -1
  23. package/dist/tools/gimp-adjustment-tools.js +3 -3
  24. package/dist/tools/gimp-core-tools.js +7 -8
  25. package/dist/tools/gimp-document-tools.js +4 -1
  26. package/dist/tools/gimp-effect-tools.js +2 -2
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +35 -5
  29. package/dist/tools/gimp-layer-tools.js +5 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-selection-tools.js +374 -0
  32. package/dist/tools/gimp-shared.js +12 -0
  33. package/dist/tools/gimp-text-tools.js +181 -0
  34. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  35. package/dist/tools/gimp-verify-tools.js +2 -13
  36. package/dist/tools/image-placement-tools.js +6 -1
  37. package/dist/tools/sky-tools.js +2 -1
  38. package/dist/update/check.js +2 -0
  39. package/dist/utils/gimp-path.js +18 -22
  40. package/dist/utils/local-path.js +99 -0
  41. package/dist/utils/operation-timeouts.js +7 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +2 -2
  44. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -423,6 +423,16 @@ _TRANSFER_MODE_ENUM = {
423
423
  'highlights': Gimp.TransferMode.HIGHLIGHTS,
424
424
  }
425
425
 
426
+ # gimp_transform_layer's `interpolation` choices, keyed the same way lib.TRANSFORM_LAYER_
427
+ # INTERPOLATIONS validates them against.
428
+ _INTERPOLATION_ENUM = {
429
+ 'none': Gimp.InterpolationType.NONE,
430
+ 'linear': Gimp.InterpolationType.LINEAR,
431
+ 'cubic': Gimp.InterpolationType.CUBIC,
432
+ 'nohalo': Gimp.InterpolationType.NOHALO,
433
+ 'lohalo': Gimp.InterpolationType.LOHALO,
434
+ }
435
+
426
436
 
427
437
  def _set_exposure(cfg, params):
428
438
  cfg.set_property('exposure', params['exposure'])
@@ -579,13 +589,6 @@ SETTERS = {
579
589
  }
580
590
 
581
591
 
582
- def _channel_by_name(img, name):
583
- for ch in img.get_channels():
584
- if ch.get_name() == name:
585
- return ch
586
- raise ValueError('no mask channel named %r (create one with create_mask)' % name)
587
-
588
-
589
592
  def _append_masked(img, layer, f, mask):
590
593
  """Append f, confined to channel `mask` if given. A filter appended while a selection is
591
594
  active keeps that selection as its own mask, which persists after the selection is
@@ -610,7 +613,7 @@ def _append_masked(img, layer, f, mask):
610
613
  runs before raising either way, so a refused filter that never actually attached to anything
611
614
  doesn't linger as an orphaned DrawableFilter object."""
612
615
  if mask:
613
- img.select_item(Gimp.ChannelOps.REPLACE, _channel_by_name(img, mask))
616
+ img.select_item(Gimp.ChannelOps.REPLACE, _require_channel(img, mask))
614
617
  else:
615
618
  Gimp.Selection.none(img)
616
619
  try:
@@ -635,12 +638,14 @@ def _append_masked(img, layer, f, mask):
635
638
  Gimp.Selection.none(img)
636
639
 
637
640
 
638
- def _apply_filter(img, args, operation, params, default_name, type_=None):
641
+ def _apply_filter(img, args, operation, params, default_name, type_=None, tag=None):
639
642
  """Create (or, with filter_id, update in place) a filter and record it in the ledger.
640
643
  A new filter may be confined to a mask channel (`mask`); a re-edit keeps the mask the
641
644
  filter was created with. `type_` is the user-facing adjust `type` (e.g. "shadows_highlights")
642
645
  recorded alongside `operation` (the GEGL op name) so a filter listing can report what the
643
- model asked for, not just the GEGL internals."""
646
+ model asked for, not just the GEGL internals. `tag`, when given, marks the record as made by
647
+ another op (`lib.MATCH_TAG`) so that op can find its own filters later; a re-edit keeps the
648
+ tag the record already had."""
644
649
  filters, unknown = _ledger_get(img)
645
650
  if args.get('filter_id') is not None:
646
651
  f = _existing_filter(img, args, operation)
@@ -655,17 +660,28 @@ def _apply_filter(img, args, operation, params, default_name, type_=None):
655
660
  SETTERS[operation](f.get_config(), params)
656
661
  params['mask'] = args.get('mask')
657
662
  _append_masked(img, layer, f, params['mask'])
658
- filters[f.get_name()] = {'operation': operation, 'type': type_, 'params': params}
663
+ record = {'operation': operation, 'type': type_, 'params': params}
664
+ tag = tag or filters.get(f.get_name(), {}).get('tag')
665
+ if tag:
666
+ record['tag'] = tag
667
+ filters[f.get_name()] = record
659
668
  _ledger_put(img, filters, unknown)
660
669
  return {'filter_id': f.get_id(), 'name': f.get_name(), 'type': type_, 'mask': params['mask']}
661
670
 
662
671
 
663
672
  # ---- operations -------------------------------------------------------------------------
664
673
 
674
+ def _document_ids():
675
+ """Ids of the open documents. The preview proxies in `PROXIES` are real GIMP images but belong
676
+ to the bridge, so they are never listed."""
677
+ hidden = [p.get_id() for p in PROXIES.values() if p.is_valid()]
678
+ return lib.visible_image_ids([i.get_id() for i in Gimp.get_images()], hidden)
679
+
680
+
665
681
  def op_ping(args):
666
682
  major, minor, micro = lib.parse_gimp_version(Gimp.version())
667
683
  return {'major': major, 'minor': minor, 'micro': micro,
668
- 'images': [i.get_id() for i in Gimp.get_images()]}
684
+ 'images': _document_ids()}
669
685
 
670
686
 
671
687
  # `precision` open-time promotion, using the enum names GIMP 3 actually exposes (verified live).
@@ -678,6 +694,67 @@ _PRECISION_ENUM = {
678
694
  }
679
695
 
680
696
 
697
+ # Rotation/flip steps (`lib.EXIF_ORIENTATION_STEPS`) as GIMP calls. The image variants act on a
698
+ # whole image; the layer variants on one (possibly unattached) layer.
699
+ _ORIENTATION_IMAGE_STEPS = {
700
+ 'cw90': lambda img: img.rotate(Gimp.RotationType.DEGREES90),
701
+ 'cw180': lambda img: img.rotate(Gimp.RotationType.DEGREES180),
702
+ 'cw270': lambda img: img.rotate(Gimp.RotationType.DEGREES270),
703
+ 'flip_h': lambda img: img.flip(Gimp.OrientationType.HORIZONTAL),
704
+ 'flip_v': lambda img: img.flip(Gimp.OrientationType.VERTICAL),
705
+ }
706
+ _ORIENTATION_LAYER_STEPS = {
707
+ 'cw90': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES90, True, 0, 0),
708
+ 'cw180': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES180, True, 0, 0),
709
+ 'cw270': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES270, True, 0, 0),
710
+ 'flip_h': lambda layer: layer.transform_flip_simple(Gimp.OrientationType.HORIZONTAL, True, 0),
711
+ 'flip_v': lambda layer: layer.transform_flip_simple(Gimp.OrientationType.VERTICAL, True, 0),
712
+ }
713
+
714
+ # Formats whose loader leaves EXIF orientation unapplied in a non-interactive load. Only JPEG is
715
+ # rotated here: other loaders apply their own orientation (HEIF's container rotation, TIFF's
716
+ # orientation tag, raw-develop plug-ins), and `.xcf` stores upright pixels, so rotating them
717
+ # again would turn them twice.
718
+ _ORIENTATION_EXTENSIONS = {'.jpg', '.jpeg', '.jpe', '.jfif'}
719
+
720
+
721
+ def _file_orientation(path, ext):
722
+ """(orientation, metadata) from the source file at `path`: the EXIF Orientation (2-8) and the
723
+ file's own metadata object, or (None, None) when the tag is missing, normal (1), unreadable,
724
+ or the format is not one this bridge orients. Read from the file itself: the metadata a load
725
+ attaches to the image is not a reliable source (it can carry another file's tags)."""
726
+ if ext not in _ORIENTATION_EXTENSIONS:
727
+ return None, None
728
+ try:
729
+ md = Gimp.Metadata.load_from_file(Gio.File.new_for_path(path))
730
+ except Exception:
731
+ return None, None
732
+ orientation = lib.read_exif_orientation(md)
733
+ if orientation is None or orientation == 1:
734
+ return None, None
735
+ return orientation, md
736
+
737
+
738
+ def _upright_open_image(img, orientation, md):
739
+ """Turns a freshly loaded image upright for the EXIF `orientation` (2-8) and gives it the
740
+ source file's own metadata with the orientation tag dropped (absent means normal; a tag SET
741
+ to 1 does not reliably stick), so a viewer of a later copy cannot rotate it again."""
742
+ for step in lib.exif_orientation_steps(orientation):
743
+ _ORIENTATION_IMAGE_STEPS[step](img)
744
+ # The pixels are already upright; metadata is a courtesy to later copies and never fails an open.
745
+ try:
746
+ if lib.clear_exif_orientation(md):
747
+ img.set_metadata(md)
748
+ except Exception:
749
+ pass
750
+
751
+
752
+ def _upright_placed_layer(layer, orientation):
753
+ """Turns a freshly loaded, unattached layer upright for the EXIF `orientation` (2-8)."""
754
+ for step in lib.exif_orientation_steps(orientation):
755
+ _ORIENTATION_LAYER_STEPS[step](layer)
756
+
757
+
681
758
  def op_open(args):
682
759
  path = lib.require(args, 'path')
683
760
  if not os.path.exists(path):
@@ -712,6 +789,9 @@ def op_open(args):
712
789
  # op here works on the whole canvas, and a transform with a selection active moves only
713
790
  # the selected pixels (leaving a floating selection), so it is cleared on the way in.
714
791
  Gimp.Selection.none(img)
792
+ orientation_applied, source_md = _file_orientation(path, ext)
793
+ if orientation_applied is not None:
794
+ _upright_open_image(img, orientation_applied, source_md)
715
795
  if precision != 'keep':
716
796
  # `convert_precision` does NOT raise on failure -- verified live that it returns
717
797
  # plain `False` (GIMP logs a "Calling error" to stderr, e.g. "must not be of type
@@ -735,7 +815,10 @@ def op_open(args):
735
815
  except Exception:
736
816
  img.delete()
737
817
  raise
738
- return _describe(img)
818
+ result = _describe(img)
819
+ if orientation_applied is not None:
820
+ result['orientation_applied'] = orientation_applied
821
+ return result
739
822
 
740
823
 
741
824
  def _existing_ledger_params(img, args, operation):
@@ -912,6 +995,340 @@ def op_effect(args):
912
995
  return _apply_filter(img, args, operation, params, default_name, type_=effect_type)
913
996
 
914
997
 
998
+ # ---- match_layer ------------------------------------------------------------------------
999
+
1000
+ def _match_hist(drawable, channel):
1001
+ """(mean, std_dev, pixel count) of `channel` over `drawable` under the image's current
1002
+ selection, in 0-255. A selection-aware read (verified live): with no selection it covers the
1003
+ whole drawable, so callers refuse an EMPTY selection before asking. GIMP reports the mean and
1004
+ spread in 0-255 for 8-bit images and in 0-1 for every higher precision (verified live)."""
1005
+ ok, mean, std_dev, _median, _pixels, count, _pct = drawable.histogram(CHANNELS[channel], 0.0, 1.0)
1006
+ if not ok:
1007
+ raise lib.OpError('gimp_op_failed', 'GIMP could not read the %s histogram' % channel)
1008
+ scale = lib.histogram_scale(drawable.get_image().get_precision().value_nick)
1009
+ return mean * scale, std_dev * scale, int(round(count))
1010
+
1011
+
1012
+ def _match_select_visible(dup, target):
1013
+ """Select what `target` shows: its alpha (hardened to 50%) intersected with its layer mask."""
1014
+ dup.select_item(Gimp.ChannelOps.REPLACE, target)
1015
+ mask = target.get_mask()
1016
+ if mask is not None:
1017
+ dup.select_item(Gimp.ChannelOps.INTERSECT, mask)
1018
+ Gimp.Selection.sharpen(dup)
1019
+
1020
+
1021
+ def _match_render(img, layer, roi, previous_filter_indexes, mode):
1022
+ """A throwaway duplicate of `img`, cropped to `roi` (x, y, w, h), scaled down by
1023
+ `lib.match_measure_scale`, with the layer's previous match filters removed. `mode` 'source'
1024
+ keeps only the layer (and its groups) visible; 'below' hides it and everything above it.
1025
+ Returns (dup, target, factor); the caller selects, flattens, and deletes `dup`."""
1026
+ all_ids = [l.get_id() for l in _all_layers(img)]
1027
+ dup = img.duplicate()
1028
+ try:
1029
+ dup_layers = _all_layers(dup)
1030
+ if len(dup_layers) != len(all_ids):
1031
+ raise lib.OpError('gimp_op_failed', 'could not duplicate the layer stack for measuring')
1032
+ target = dup_layers[all_ids.index(layer.get_id())]
1033
+ ancestors = {a.get_id() for a in _layer_ancestors(target)}
1034
+ x, y, w, h = roi
1035
+ if (w, h) != (img.get_width(), img.get_height()):
1036
+ dup.crop(w, h, x, y)
1037
+ factor = lib.match_measure_scale(w, h)
1038
+ if factor < 1.0:
1039
+ dup.scale(max(1, int(round(w * factor))), max(1, int(round(h * factor))))
1040
+ target_filters = target.get_filters()
1041
+ for i in previous_filter_indexes:
1042
+ target_filters[i].delete()
1043
+ if mode == 'source':
1044
+ for l in dup_layers:
1045
+ l.set_visible(l.get_id() == target.get_id() or l.get_id() in ancestors)
1046
+ target.set_opacity(100.0)
1047
+ target.set_mode(Gimp.LayerMode.NORMAL)
1048
+ else:
1049
+ index = dup_layers.index(target)
1050
+ above_or_self = {l.get_id() for l in dup_layers[:index + 1]} - ancestors
1051
+ for l in dup_layers:
1052
+ if l.get_id() in above_or_self:
1053
+ l.set_visible(False)
1054
+ if not any(l.get_visible() and not l.is_group() for l in dup_layers):
1055
+ raise ValueError('there are no visible layers below this layer to match against')
1056
+ return dup, target, factor
1057
+ except Exception:
1058
+ dup.delete()
1059
+ raise
1060
+
1061
+
1062
+ def _match_stats(flat, channels, factor):
1063
+ """Per-channel (mean, std_dev, full-resolution pixel count) on a measuring copy."""
1064
+ out = {}
1065
+ for c in channels:
1066
+ mean, std, count = _match_hist(flat, c)
1067
+ out[c] = (mean, std, lib.match_full_res_count(count, factor))
1068
+ return out
1069
+
1070
+
1071
+ def _match_measure_source(img, layer, roi, previous_filter_indexes, channels):
1072
+ dup, target, factor = _match_render(img, layer, roi, previous_filter_indexes, 'source')
1073
+ try:
1074
+ _match_select_visible(dup, target)
1075
+ if Gimp.Selection.is_empty(dup):
1076
+ raise ValueError('the layer has no visible pixels to measure')
1077
+ flat = dup.flatten()
1078
+ return _match_stats(flat, channels, factor)
1079
+ finally:
1080
+ dup.delete()
1081
+
1082
+
1083
+ def _match_measure_reference(img, layer, roi, previous_filter_indexes, channels, opts):
1084
+ dup, target, factor = _match_render(img, layer, roi, previous_filter_indexes, 'below')
1085
+ try:
1086
+ if opts['reference'] == 'surround':
1087
+ _match_select_visible(dup, target)
1088
+ if Gimp.Selection.is_empty(dup):
1089
+ raise ValueError('the layer has no visible pixels to measure')
1090
+ visible = Gimp.Selection.save(dup)
1091
+ Gimp.Selection.grow(dup, lib.match_scaled_px(opts['surround_px'], factor))
1092
+ dup.select_item(Gimp.ChannelOps.SUBTRACT, visible)
1093
+ Gimp.Selection.sharpen(dup)
1094
+ if Gimp.Selection.is_empty(dup):
1095
+ raise ValueError(
1096
+ 'there is no canvas around the layer to sample (it fills the canvas); use '
1097
+ "reference 'below'"
1098
+ )
1099
+ else:
1100
+ _ok, ox, oy = target.get_offsets()
1101
+ dup.select_rectangle(Gimp.ChannelOps.REPLACE, ox, oy, target.get_width(), target.get_height())
1102
+ if Gimp.Selection.is_empty(dup):
1103
+ raise ValueError('the layer has no visible pixels to measure')
1104
+ flat = dup.flatten()
1105
+ return _match_stats(flat, channels, factor)
1106
+ finally:
1107
+ dup.delete()
1108
+
1109
+
1110
+ def _delete_filters(img, created):
1111
+ """Remove filters this call just made (by id) and their ledger records; never raises."""
1112
+ names = set()
1113
+ for c in created:
1114
+ try:
1115
+ _layer, f = _find_filter(img, c['filter_id'])
1116
+ names.add(f.get_name())
1117
+ f.delete()
1118
+ except Exception:
1119
+ pass
1120
+ if names:
1121
+ try:
1122
+ filters, unknown = _ledger_get(img)
1123
+ for name in names:
1124
+ filters.pop(name, None)
1125
+ _ledger_put(img, filters, unknown, removed=names)
1126
+ except Exception:
1127
+ pass
1128
+
1129
+
1130
+ class _PreservedSelection(object):
1131
+ """Keeps the user's selection across ops that clear or replace it: the selection is saved to
1132
+ a temporary channel on entry and restored (or cleared, if there was none) on exit, whichever
1133
+ way the body ends."""
1134
+
1135
+ def __init__(self, img):
1136
+ self.img = img
1137
+ self.saved = None
1138
+
1139
+ def __enter__(self):
1140
+ if not Gimp.Selection.is_empty(self.img):
1141
+ self.saved = Gimp.Selection.save(self.img)
1142
+ return self
1143
+
1144
+ def __exit__(self, exc_type, exc, tb):
1145
+ try:
1146
+ if self.saved is not None:
1147
+ self.img.select_item(Gimp.ChannelOps.REPLACE, self.saved)
1148
+ else:
1149
+ Gimp.Selection.none(self.img)
1150
+ finally:
1151
+ if self.saved is not None:
1152
+ self.img.remove_channel(self.saved)
1153
+ return False
1154
+
1155
+
1156
+ def _match_refine_edges(img, layer, contract_px, feather_px):
1157
+ """Contract then feather the layer's mask (one made from its alpha when it has none). Only
1158
+ the part of the mask on the canvas is rewritten; alpha is never touched. The caller holds the
1159
+ selection (`_PreservedSelection`). Returns whether a mask was created."""
1160
+ created = False
1161
+ mask = layer.get_mask()
1162
+ if mask is None:
1163
+ mask = layer.create_mask(Gimp.AddMaskType.ALPHA)
1164
+ layer.add_mask(mask)
1165
+ created = True
1166
+ try:
1167
+ img.select_item(Gimp.ChannelOps.REPLACE, mask)
1168
+ if contract_px:
1169
+ _shrink_edge_locked(img, contract_px)
1170
+ if feather_px:
1171
+ Gimp.Selection.feather(img, feather_px)
1172
+ _ok, ox, oy = layer.get_offsets()
1173
+ cw, ch = img.get_width(), img.get_height()
1174
+ x0, y0 = max(0, ox), max(0, oy)
1175
+ x1, y1 = min(cw, ox + layer.get_width()), min(ch, oy + layer.get_height())
1176
+ if x1 > x0 and y1 > y0:
1177
+ data = img.get_selection().get_buffer().get(
1178
+ Gegl.Rectangle.new(x0, y0, x1 - x0, y1 - y0), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
1179
+ )
1180
+ buf = mask.get_buffer()
1181
+ buf.set(Gegl.Rectangle.new(x0 - ox, y0 - oy, x1 - x0, y1 - y0), "Y' u8", data)
1182
+ buf.flush()
1183
+ mask.update(x0 - ox, y0 - oy, x1 - x0, y1 - y0)
1184
+ except Exception:
1185
+ if created:
1186
+ try:
1187
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
1188
+ except Exception:
1189
+ pass
1190
+ raise
1191
+ return created
1192
+
1193
+
1194
+ def op_match_layer(args):
1195
+ """Pull a pasted layer's colour and tone toward what surrounds it, as live curves filters.
1196
+
1197
+ Measure (on throwaway duplicates cropped to the layer's box grown by the surround, deleted on
1198
+ every path -- the document, its selection and its visibility are not touched while
1199
+ measuring): the layer's visible pixels (alpha AND layer mask, its own non-match filters
1200
+ rendered) against the reference -- a ring of canvas around the visible area ('surround'), or
1201
+ the whole composite of everything below under the layer's box ('below'). Per channel,
1202
+ `lib.match_transfer` gives the linear map and `lib.match_curve_points` its curve.
1203
+
1204
+ 'both' and 'color' write red/green/blue curves (matching each channel's mean and spread
1205
+ already matches tone, so 'both' adds no value curve on top); 'tone' writes one value curve
1206
+ from the VALUE (max of R,G,B) statistics. Each curve is a `gimp:curves` filter named 'Match
1207
+ <channel>', ledgered with `lib.MATCH_TAG`; a repeat call deletes the layer's previous tagged
1208
+ filters and writes fresh ones. The edge options then refine the layer mask."""
1209
+ img = _image(args)
1210
+ layer = _layer(img, args)
1211
+ if layer.is_group():
1212
+ raise ValueError(
1213
+ 'cannot match a layer group: it has no pixels of its own; match a layer inside it'
1214
+ )
1215
+ if layer.is_text_layer():
1216
+ raise ValueError('cannot match a text layer: bake it to pixels first (gimp_bake)')
1217
+ if layer.get_lock_content():
1218
+ raise ValueError('layer %r is locked; unlock it first' % layer.get_name())
1219
+ if lib.is_linear_precision(img.get_precision().value_nick):
1220
+ raise ValueError(
1221
+ 'this image stores linear light (%s); convert it to a non-linear precision first'
1222
+ % img.get_precision().value_nick
1223
+ )
1224
+ lw, lh = layer.get_width(), layer.get_height()
1225
+ opts = lib.validate_match_args(args, lw, lh)
1226
+ if opts['match'] != 'tone' and img.get_base_type() != Gimp.ImageBaseType.RGB:
1227
+ raise ValueError("this image is not RGB; use match 'tone' (or convert it to RGB first)")
1228
+ _ok, ox, oy = layer.get_offsets()
1229
+ pad = opts['surround_px'] if opts['reference'] == 'surround' else 0
1230
+ roi = lib.match_roi(ox, oy, lw, lh, img.get_width(), img.get_height(), pad)
1231
+ if roi is None:
1232
+ raise ValueError('the layer is entirely outside the canvas')
1233
+
1234
+ ledger, _unknown = _ledger_get(img)
1235
+ previous = [
1236
+ (i, f) for i, f in enumerate(layer.get_filters())
1237
+ if ledger.get(f.get_name(), {}).get('tag') == lib.MATCH_TAG
1238
+ and ledger[f.get_name()].get('operation') == f.get_operation_name()
1239
+ ]
1240
+ previous_indexes = [i for i, _f in previous]
1241
+ channels = ['value'] if opts['match'] == 'tone' else ['red', 'green', 'blue']
1242
+
1243
+ source = _match_measure_source(img, layer, roi, previous_indexes, channels)
1244
+ reference = _match_measure_reference(img, layer, roi, previous_indexes, channels, opts)
1245
+ source_pixels = min(count for _m, _s, count in source.values())
1246
+ reference_pixels = min(count for _m, _s, count in reference.values())
1247
+ if source_pixels < lib.MATCH_MIN_PIXELS:
1248
+ raise ValueError(
1249
+ 'the layer has only %d visible pixels to measure (need at least %d)'
1250
+ % (source_pixels, lib.MATCH_MIN_PIXELS)
1251
+ )
1252
+ if reference_pixels < lib.MATCH_MIN_PIXELS:
1253
+ raise ValueError(
1254
+ 'only %d reference pixels were found around the layer (need at least %d); raise '
1255
+ "surround_px or use reference 'below'" % (reference_pixels, lib.MATCH_MIN_PIXELS)
1256
+ )
1257
+
1258
+ plans = []
1259
+ for channel in channels:
1260
+ mu_s, sd_s, _n = source[channel]
1261
+ mu_r, sd_r, _n = reference[channel]
1262
+ t = lib.match_transfer(mu_s, sd_s, mu_r, sd_r, opts['s'])
1263
+ points = lib.match_curve_points(mu_s, t['gain'], t['shift'])
1264
+ plans.append((channel, mu_s, sd_s, mu_r, sd_r, t, points))
1265
+
1266
+ created, replaced, edge = [], [], None
1267
+ try:
1268
+ with _PreservedSelection(img):
1269
+ # Replace, don't stack: this layer's previous match filters go first.
1270
+ if previous:
1271
+ names = set()
1272
+ for _i, f in previous:
1273
+ names.add(f.get_name())
1274
+ replaced.append(f.get_id())
1275
+ f.delete()
1276
+ filters, unknown = _ledger_get(img)
1277
+ for name in names:
1278
+ filters.pop(name, None)
1279
+ _ledger_put(img, filters, unknown, removed=names)
1280
+ # All or nothing: a partial set would leave a colour cast.
1281
+ try:
1282
+ for channel, _ms, _ss, _mr, _sr, _t, points in plans:
1283
+ created.append(_apply_filter(
1284
+ img, {'layer_id': layer.get_id(), 'name': 'Match %s' % channel},
1285
+ 'gimp:curves', {'channel': channel, 'points': points}, 'Match %s' % channel,
1286
+ type_='curves', tag=lib.MATCH_TAG,
1287
+ ))
1288
+ except Exception:
1289
+ _delete_filters(img, created)
1290
+ raise
1291
+ if opts['edge_contract_px'] or opts['edge_feather_px']:
1292
+ mask_created = _match_refine_edges(
1293
+ img, layer, opts['edge_contract_px'], opts['edge_feather_px']
1294
+ )
1295
+ edge = {
1296
+ 'contract_px': opts['edge_contract_px'],
1297
+ 'feather_px': opts['edge_feather_px'],
1298
+ 'mask_created': mask_created,
1299
+ }
1300
+ finally:
1301
+ _drop_proxies(img.get_id())
1302
+
1303
+ def stat(mean, std):
1304
+ return {'mean': round(mean, 2), 'std': round(std, 2)}
1305
+
1306
+ return {
1307
+ 'layer': layer.get_name(),
1308
+ 'layer_id': layer.get_id(),
1309
+ 'match': opts['match'],
1310
+ 'reference': opts['reference'],
1311
+ 'strength': opts['strength'],
1312
+ 'surround_px': opts['surround_px'] if opts['reference'] == 'surround' else None,
1313
+ 'filters': [
1314
+ {'filter_id': c['filter_id'], 'name': c['name'], 'channel': p[0]}
1315
+ for c, p in zip(created, plans)
1316
+ ],
1317
+ 'replaced_filter_ids': replaced,
1318
+ 'measured': {'layer_pixels': source_pixels, 'reference_pixels': reference_pixels},
1319
+ 'channels': {
1320
+ channel: {
1321
+ 'before': stat(mu_s, sd_s),
1322
+ 'after': stat(t['mean_after'], t['std_after']),
1323
+ 'reference': stat(mu_r, sd_r),
1324
+ 'gain': round(t['gain'], 3),
1325
+ }
1326
+ for channel, mu_s, sd_s, mu_r, sd_r, t, _pts in plans
1327
+ },
1328
+ 'edge': edge,
1329
+ }
1330
+
1331
+
915
1332
  def op_filter(args):
916
1333
  """Stack management: `op` in list | set_visibility | delete. There is deliberately no
917
1334
  `reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
@@ -969,7 +1386,7 @@ def _layer_node_shallow(layer):
969
1386
  `get_children()`, but is still checked rather than trusted blindly -- offsets fall back to
970
1387
  null rather than reporting a wrong position."""
971
1388
  ok, off_x, off_y = layer.get_offsets()
972
- return {
1389
+ node = {
973
1390
  'layer_id': layer.get_id(),
974
1391
  'name': layer.get_name(),
975
1392
  'opacity': layer.get_opacity(),
@@ -981,6 +1398,14 @@ def _layer_node_shallow(layer):
981
1398
  'is_text_layer': layer.is_text_layer(),
982
1399
  'children': [],
983
1400
  }
1401
+ if node['is_text_layer']:
1402
+ # A text layer this engine can't read (an odd unit, a missing font) still describes as
1403
+ # a text layer; it never fails the whole document.
1404
+ try:
1405
+ node.update(_text_fields(layer))
1406
+ except Exception:
1407
+ pass
1408
+ return node
984
1409
 
985
1410
 
986
1411
  def _build_layer_tree(top_layers, max_nodes=MAX_DESCRIBE_LAYER_NODES):
@@ -1240,7 +1665,7 @@ def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
1240
1665
  )
1241
1666
 
1242
1667
 
1243
- def _snapshot_effect_transform(img, op_name, transform_fn):
1668
+ def _snapshot_effect_transform(img, op_name, transform_fn, layers=None):
1244
1669
  """Precompute every ledgered effect filter's new params from a SNAPSHOT of the current
1245
1670
  ledger, validate each against this bridge's own field ranges, and raise -- refusing `op_name`
1246
1671
  outright -- if anything would land out of range, ALL BEFORE anything is mutated. Working from
@@ -1255,13 +1680,18 @@ def _snapshot_effect_transform(img, op_name, transform_fn):
1255
1680
  actually changed -- callers apply these with `_apply_planned_effect_transform` AFTER the
1256
1681
  geometry mutation itself. A filter transform_fn has nothing to change for (black_white,
1257
1682
  add_noise, and every gimp_add_adjustment type -- out of scope for this table, see lib.py's own
1258
- comment) comes back with the SAME values (by `==`) and is simply absent from the result."""
1683
+ comment) comes back with the SAME values (by `==`) and is simply absent from the result.
1684
+
1685
+ `layers`, when given, scopes the walk to exactly those layers instead of the whole image --
1686
+ `gimp_transform_layer`'s own use, scoped to the target's subtree (`_layer_subtree`), since
1687
+ that op only ever transforms one layer (or, for a group, its own descendants), never every
1688
+ layer in the image the way the whole-canvas geometry ops below do."""
1259
1689
  filters, _unknown = _ledger_get(img)
1260
1690
  if not filters:
1261
1691
  return {}
1262
1692
  snapshot = {name: (rec['operation'], dict(rec['params'])) for name, rec in filters.items()}
1263
1693
  planned = {}
1264
- for layer in _all_layers(img):
1694
+ for layer in (layers if layers is not None else _all_layers(img)):
1265
1695
  for f in layer.get_filters():
1266
1696
  entry = snapshot.get(f.get_name())
1267
1697
  if entry is None or entry[0] != f.get_operation_name():
@@ -1816,12 +2246,18 @@ def op_export(args):
1816
2246
 
1817
2247
  def _replace_named_channel(img, name, w, h):
1818
2248
  """Remove any existing channel called `name` and insert a fresh, black-filled one of the
1819
- image's own size -- `op_create_mask`'s replace-by-name semantics."""
2249
+ image's own size -- `op_create_mask`'s replace-by-name semantics. Restores the image's
2250
+ selected layers afterward: `insert_channel` changes them as a side effect (verified live, the
2251
+ same class of surprise `op_place_image`'s own comment documents for `insert_layer`), which
2252
+ would otherwise silently retarget a later layer-defaulting call (e.g. `gimp_add_adjustment`
2253
+ with no `layer`) at whatever layer a channel happened to insert next to."""
2254
+ original_selection = img.get_selected_layers()
1820
2255
  for existing in img.get_channels():
1821
2256
  if existing.get_name() == name:
1822
2257
  img.remove_channel(existing)
1823
2258
  ch = Gimp.Channel.new(img, name, w, h, 50.0, Gegl.Color.new('black'))
1824
2259
  img.insert_channel(ch, None, 0)
2260
+ img.set_selected_layers(original_selection)
1825
2261
  return ch
1826
2262
 
1827
2263
 
@@ -1856,6 +2292,31 @@ def _mask_name_in_use(img, name):
1856
2292
  return any(rec.get('params', {}).get('mask') == name for rec in filters.values())
1857
2293
 
1858
2294
 
2295
+ def _refuse_mask_in_use(img, name):
2296
+ if _mask_name_in_use(img, name):
2297
+ raise ValueError(
2298
+ 'mask %r is already used by an existing filter; delete that filter or use a '
2299
+ 'different name' % name
2300
+ )
2301
+
2302
+
2303
+ def _find_channel(img, name):
2304
+ for ch in img.get_channels():
2305
+ if ch.get_name() == name:
2306
+ return ch
2307
+ return None
2308
+
2309
+
2310
+ def _require_channel(img, name):
2311
+ """`_find_channel`, refusing a miss with the one neutral message every caller (the filter
2312
+ mask lookup and every gimp_select-family channel reference) shares -- a channel can come from
2313
+ gimp_select, gimp_layer_mask op=create, or load_mask, so no single tool name belongs here."""
2314
+ ch = _find_channel(img, name)
2315
+ if ch is None:
2316
+ raise ValueError('no channel named %r' % name)
2317
+ return ch
2318
+
2319
+
1859
2320
  def op_create_mask(args):
1860
2321
  """Geometric mask -> a named channel (replacing any same-named channel not already in use as
1861
2322
  a filter's mask -- see `_mask_name_in_use`), leaving the selection cleared afterward.
@@ -1873,15 +2334,14 @@ def op_create_mask(args):
1873
2334
  of how this function exits, rather than manually saving and restoring each property by hand.
1874
2335
  The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
1875
2336
  has NO effect on a Channel fill's actual output either way: forcing RGB_PERCEPTUAL, forcing
1876
- RGB_LINEAR, and leaving the context at whatever it already was all produced byte-identical
1877
- output (an exact linear ramp -- x=128 reads 128) once read back correctly. What DOES control
1878
- the crossing point is the buffer format every reader of this channel (`_channel_coverage`,
1879
- and the selection copy above) uses -- `"Y' u8"` (perceptual/gamma-encoded, matching the
1880
- channel's own storage), not `'Y u8'` (linear light, which compresses the readback toward black,
1881
- e.g. the geometric midpoint reading ~55 instead of ~128; this is what an earlier, incorrect
1882
- version of this comment blamed on the blend color space instead). RGB_PERCEPTUAL is kept set
1883
- here anyway as the explicit, correct-if-it-ever-starts-mattering choice for a caller who thinks
1884
- in 0-255 terms, not because it changes anything measured today.
2337
+ RGB_LINEAR, and leaving the context at whatever it already was all produce the same
2338
+ byte-identical ramp (an exact linear ramp -- x=128 reads 128) when read back with `"Y' u8"`.
2339
+ What DOES control the crossing point is the buffer format every reader of this channel
2340
+ (`_channel_coverage`, and the selection copy above) uses -- `"Y' u8"` (perceptual/gamma-encoded,
2341
+ matching the channel's own storage), not `'Y u8'` (linear light, which compresses the readback
2342
+ toward black, e.g. the geometric midpoint reading ~55 instead of ~128). RGB_PERCEPTUAL is kept
2343
+ set here anyway as the explicit, correct-if-it-ever-starts-mattering choice for a caller who
2344
+ thinks in 0-255 terms, not because it changes anything measured today.
1885
2345
 
1886
2346
  `feather_px` has no effect here (a gradient is already a continuous ramp, nothing to feather);
1887
2347
  `invert` swaps which end is black vs white."""
@@ -1890,74 +2350,98 @@ def op_create_mask(args):
1890
2350
  if type_ not in lib.MASK_TYPES:
1891
2351
  raise ValueError('type must be one of %s' % sorted(lib.MASK_TYPES))
1892
2352
  name = args.get('name', 'Mask')
1893
- if _mask_name_in_use(img, name):
1894
- raise ValueError(
1895
- 'mask %r is already used by an existing filter; delete that filter or use a '
1896
- 'different name' % name
1897
- )
2353
+ _refuse_mask_in_use(img, name)
1898
2354
  invert = bool(args.get('invert', False))
1899
2355
  feather_px = lib.validate_feather_px(args.get('feather_px', 0))
1900
2356
  w, h = img.get_width(), img.get_height()
1901
2357
 
1902
- if type_ in ('rectangle', 'ellipse'):
1903
- x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
1904
- width, height = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
1905
- if type_ == 'rectangle':
1906
- img.select_rectangle(Gimp.ChannelOps.REPLACE, x, y, width, height)
1907
- else:
1908
- img.select_ellipse(Gimp.ChannelOps.REPLACE, x, y, width, height)
1909
- if invert:
1910
- Gimp.Selection.invert(img)
1911
- if feather_px > 0:
1912
- Gimp.Selection.feather(img, feather_px)
1913
- ch = _replace_named_channel(img, name, w, h)
1914
- # "Y' u8" (perceptual), not "Y u8" (linear) -- see `_channel_coverage`'s comment. A hard
1915
- # selection is 0/255 either way, but a FEATHERED one has a real falloff whose shape this
1916
- # format choice controls.
1917
- sel_data = img.get_selection().get_buffer().get(
1918
- Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
1919
- )
1920
- ch_buf = ch.get_buffer()
1921
- ch_buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", sel_data)
1922
- ch_buf.flush()
1923
- ch.update(0, 0, w, h)
1924
- else:
1925
- ch = _replace_named_channel(img, name, w, h)
1926
- black, white = ('white', 'black') if invert else ('black', 'white')
1927
- # A gradient fill paints `start_color` at (x1,y1) and `end_color` at (x2,y2). For
1928
- # gradient_linear those points are the caller's own start/end -- black-at-x1 ramps
1929
- # toward white-at-x2 exactly as given (verified live). For gradient_radial (x1,y1) is
1930
- # always the CENTER and (x2,y2) the radius edge, and the intuitive reading of a radial
1931
- # mask is a spotlight -- full effect (white) at the center, fading to none (black) at
1932
- # the edge -- so the two colors are swapped relative to the linear case.
1933
- if type_ == 'gradient_linear':
1934
- start_color, end_color = black, white
1935
- x1, y1 = float(args.get('x1', 0)), float(args.get('y1', 0))
1936
- x2, y2 = float(args.get('x2', w)), float(args.get('y2', 0))
1937
- gtype = Gimp.GradientType.LINEAR
2358
+ try:
2359
+ if type_ in ('rectangle', 'ellipse'):
2360
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
2361
+ width, height = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
2362
+ if type_ == 'rectangle':
2363
+ img.select_rectangle(Gimp.ChannelOps.REPLACE, x, y, width, height)
2364
+ else:
2365
+ img.select_ellipse(Gimp.ChannelOps.REPLACE, x, y, width, height)
2366
+ if invert:
2367
+ Gimp.Selection.invert(img)
2368
+ if feather_px > 0:
2369
+ Gimp.Selection.feather(img, feather_px)
2370
+ ch = _replace_named_channel(img, name, w, h)
2371
+ # "Y' u8" (perceptual), not "Y u8" (linear) -- see `_channel_coverage`'s comment. A
2372
+ # hard selection is 0/255 either way, but a FEATHERED one has a real falloff whose
2373
+ # shape this format choice controls.
2374
+ sel_data = img.get_selection().get_buffer().get(
2375
+ Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
2376
+ )
2377
+ ch_buf = ch.get_buffer()
2378
+ ch_buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", sel_data)
2379
+ ch_buf.flush()
2380
+ ch.update(0, 0, w, h)
1938
2381
  else:
1939
- start_color, end_color = white, black
1940
- cx, cy = float(args.get('cx', w / 2.0)), float(args.get('cy', h / 2.0))
1941
- radius = float(args.get('radius', min(w, h) / 2.0))
1942
- x1, y1, x2, y2 = cx, cy, cx + radius, cy
1943
- gtype = Gimp.GradientType.RADIAL
1944
- Gimp.context_push()
1945
- try:
1946
- Gimp.context_set_foreground(Gegl.Color.new(start_color))
1947
- Gimp.context_set_background(Gegl.Color.new(end_color))
1948
- Gimp.context_set_gradient_fg_bg_rgb()
1949
- Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
1950
- ch.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
1951
- finally:
1952
- Gimp.context_pop()
1953
- Gimp.Selection.none(img) # a mask is referenced by NAME at filter-creation time, not by
1954
- # staying the active selection -- leaving it selected let the NEXT unmasked filter silently
1955
- # inherit it (`_append_masked` also clears defensively, but the fix belongs here too).
2382
+ ch = _replace_named_channel(img, name, w, h)
2383
+ _paint_gradient_mask(ch, type_, invert, args, w, h)
2384
+ finally:
2385
+ Gimp.Selection.none(img) # a mask is referenced by NAME at filter-creation time, not by
2386
+ # staying the active selection -- leaving it selected let the NEXT unmasked filter
2387
+ # silently inherit it (`_append_masked` also clears defensively, but the fix belongs
2388
+ # here too).
1956
2389
  _drop_proxies(img.get_id())
1957
2390
  selected, fraction = _channel_coverage(ch, w, h)
1958
2391
  return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
1959
2392
 
1960
2393
 
2394
+ def _paint_gradient_mask(ch, type_, invert, args, w, h):
2395
+ """Paint a `type_` ('gradient_linear' | 'gradient_radial') black-to-white ramp directly into
2396
+ channel `ch`. `gegl:linear-gradient`/`gegl:radial-gradient` are NOT usable as drawable filters
2397
+ (verified live: their pspecs return None, `DrawableFilter.new` fails) -- the working route is
2398
+ `Drawable.edit_gradient_fill` (verified live). `Gimp.context_push`/`context_pop` bracket every
2399
+ context change (foreground/background/gradient/blend space) so the session's ambient context
2400
+ -- which the user's own GUI or a later call might depend on -- is restored exactly regardless
2401
+ of how this function exits, rather than manually saving and restoring each property by hand.
2402
+ The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
2403
+ has NO effect on a Channel fill's actual output either way: forcing RGB_PERCEPTUAL, forcing
2404
+ RGB_LINEAR, and leaving the context at whatever it already was all produce the same
2405
+ byte-identical ramp (an exact linear ramp -- x=128 reads 128) when read back with `"Y' u8"`.
2406
+ What DOES control the crossing point is the buffer format every reader of this channel
2407
+ (`_channel_coverage`) uses -- `"Y' u8"` (perceptual/gamma-encoded, matching the channel's own
2408
+ storage), not `'Y u8'` (linear light, which compresses the readback toward black, e.g. the
2409
+ geometric midpoint reading ~55 instead of ~128). RGB_PERCEPTUAL is kept set here anyway as the
2410
+ explicit, correct-if-it-ever-starts-mattering choice for a caller who thinks in 0-255 terms, not
2411
+ because it changes anything measured today.
2412
+
2413
+ No feather here (a gradient is already a continuous ramp, nothing to feather); `invert` swaps
2414
+ which end is black vs white. Shared by `op_create_mask` and `op_select`'s own gradient_linear/
2415
+ gradient_radial modes."""
2416
+ black, white = ('white', 'black') if invert else ('black', 'white')
2417
+ # A gradient fill paints `start_color` at (x1,y1) and `end_color` at (x2,y2). For
2418
+ # gradient_linear those points are the caller's own start/end -- black-at-x1 ramps toward
2419
+ # white-at-x2 exactly as given (verified live). For gradient_radial (x1,y1) is always the
2420
+ # CENTER and (x2,y2) the radius edge, and the intuitive reading of a radial mask is a
2421
+ # spotlight -- full effect (white) at the center, fading to none (black) at the edge -- so the
2422
+ # two colors are swapped relative to the linear case.
2423
+ if type_ == 'gradient_linear':
2424
+ start_color, end_color = black, white
2425
+ x1, y1 = float(args.get('x1', 0)), float(args.get('y1', 0))
2426
+ x2, y2 = float(args.get('x2', w)), float(args.get('y2', 0))
2427
+ gtype = Gimp.GradientType.LINEAR
2428
+ else:
2429
+ start_color, end_color = white, black
2430
+ cx, cy = float(args.get('cx', w / 2.0)), float(args.get('cy', h / 2.0))
2431
+ radius = float(args.get('radius', min(w, h) / 2.0))
2432
+ x1, y1, x2, y2 = cx, cy, cx + radius, cy
2433
+ gtype = Gimp.GradientType.RADIAL
2434
+ Gimp.context_push()
2435
+ try:
2436
+ Gimp.context_set_foreground(Gegl.Color.new(start_color))
2437
+ Gimp.context_set_background(Gegl.Color.new(end_color))
2438
+ Gimp.context_set_gradient_fg_bg_rgb()
2439
+ Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
2440
+ ch.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
2441
+ finally:
2442
+ Gimp.context_pop()
2443
+
2444
+
1961
2445
  def op_describe_operation(args):
1962
2446
  """Probe: `list_properties()` (name, type, min, max, default) for a named GEGL/GIMP
1963
2447
  operation, via a transient `DrawableFilter` on a throwaway 1x1 image that is never applied
@@ -2119,9 +2603,9 @@ def _layer_ancestors(layer):
2119
2603
 
2120
2604
  def _refuse_if_masked_filters_on(img, op_name, layer):
2121
2605
  """The same refuse-rather-than-corrupt check as `_refuse_if_masked_filters`, scoped to `layer`
2122
- itself, its own subtree (`_layer_subtree` -- moving a group takes its children with it), and
2123
- its containing groups (`_layer_ancestors`) -- moving `layer` cannot misalign a masked filter
2124
- that lives on some OTHER, unrelated part of the image.
2606
+ itself, its own subtree (`_layer_subtree` -- transforming a group takes its children with
2607
+ it), and its containing groups (`_layer_ancestors`) -- transforming `layer` cannot misalign a
2608
+ masked filter that lives on some OTHER, unrelated part of the image.
2125
2609
 
2126
2610
  Duplicate-name classification runs over the WHOLE image's live filters, not just this scope: a
2127
2611
  name is ambiguous the moment it appears on more than one live filter ANYWHERE in the image,
@@ -2129,7 +2613,7 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2129
2613
  elsewhere hiding behind an unmasked one's ledger record in this scope (or the reverse) would
2130
2614
  otherwise slip through unnoticed. The refusal itself still only fires for a name that actually
2131
2615
  appears in `layer`'s own scope, so a masked filter on a completely unrelated layer never blocks
2132
- this move."""
2616
+ this transform."""
2133
2617
  scope = set(_layer_subtree(layer)) | set(_layer_ancestors(layer))
2134
2618
  scoped_names = {f.get_name() for l in scope for f in l.get_filters()}
2135
2619
  filters = _prune_stale_ledger_records(img)
@@ -2157,8 +2641,8 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2157
2641
  reasons.append(
2158
2642
  'filter(s) %s on this layer, or a group containing it, were not created by '
2159
2643
  'Editmamei (no matching ledger record for their name and operation), so whether '
2160
- 'they are masked cannot be checked -- delete them first, or move the layer before '
2161
- 'adding them'
2644
+ 'they are masked cannot be checked -- delete them first, or transform the layer '
2645
+ 'before adding them'
2162
2646
  % ', '.join(repr(n) for n in unrecognized)
2163
2647
  )
2164
2648
  if duplicated:
@@ -2182,8 +2666,8 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2182
2666
  raise ValueError(
2183
2667
  '%s would misalign the masked adjustment(s) %s: a filter\'s mask does not travel with '
2184
2668
  'content that moves beneath it, whether the filter is on this layer or a group that '
2185
- 'contains it. Move the layer before adding masked adjustments, or delete those filters '
2186
- 'first and re-create them afterwards.'
2669
+ 'contains it. Transform the layer before adding masked adjustments, or delete those '
2670
+ 'filters first and re-create them afterwards.'
2187
2671
  % (op_name, ', '.join(repr(n) for n in masked))
2188
2672
  )
2189
2673
 
@@ -2479,19 +2963,6 @@ def _op_layer_set(img, args):
2479
2963
  return {'layer_id': layer.get_id(), 'name': layer.get_name(), **changed}
2480
2964
 
2481
2965
 
2482
- def _op_layer_move(img, args):
2483
- layer = _layer(img, args)
2484
- _refuse_if_masked_filters_on(img, 'move', layer)
2485
- x, y = int(lib.require(args, 'x')), int(lib.require(args, 'y'))
2486
- _validated_move_offset(img, x, y)
2487
- try:
2488
- layer.set_offsets(x, y)
2489
- finally:
2490
- _drop_proxies(img.get_id())
2491
- ok, off_x, off_y = layer.get_offsets()
2492
- return {'layer_id': layer.get_id(), 'x': off_x if ok else None, 'y': off_y if ok else None}
2493
-
2494
-
2495
2966
  def _op_layer_reorder(img, args):
2496
2967
  layer = _layer(img, args)
2497
2968
  parent = _resolve_parent_group(img, args) if 'parent_group' in args else layer.get_parent()
@@ -2604,7 +3075,6 @@ LAYER_OPS = {
2604
3075
  'duplicate': _op_layer_duplicate,
2605
3076
  'select': _op_layer_select,
2606
3077
  'set': _op_layer_set,
2607
- 'move': _op_layer_move,
2608
3078
  'reorder': _op_layer_reorder,
2609
3079
  'merge_down': _op_layer_merge_down,
2610
3080
  'flatten': _op_layer_flatten,
@@ -2620,6 +3090,333 @@ def op_layer(args):
2620
3090
  return fn(img, args)
2621
3091
 
2622
3092
 
3093
+ # ---- gimp_text / gimp_inspect what=fonts ------------------------------------------------------
3094
+ #
3095
+ # A text layer stays a live Gimp.TextLayer: text, font, size, colour and justification are
3096
+ # properties of the layer, re-rendered by GIMP on every change. Sizes are exchanged in points and
3097
+ # stored in pixels (pt * the image's vertical resolution / 72). A rendered layer past the engine's
3098
+ # size cap is refused: a new layer is discarded, an edit is rolled back to the previous values.
3099
+
3100
+ _JUSTIFICATIONS = {
3101
+ 'left': Gimp.TextJustification.LEFT,
3102
+ 'right': Gimp.TextJustification.RIGHT,
3103
+ 'center': Gimp.TextJustification.CENTER,
3104
+ 'fill': Gimp.TextJustification.FILL,
3105
+ }
3106
+
3107
+
3108
+ def _font_list():
3109
+ """Every installed font as `{name: Gimp.Font}`. A freshly started GIMP can report an empty
3110
+ list while its font cache is still loading, so an empty result is retried briefly."""
3111
+ for _ in range(20):
3112
+ fonts = Gimp.fonts_get_list('')
3113
+ if fonts:
3114
+ return {f.get_name(): f for f in fonts}
3115
+ time.sleep(0.25)
3116
+ raise lib.OpError('gimp_op_failed', 'GIMP reports no installed fonts (the font list is empty)')
3117
+
3118
+
3119
+ def op_fonts(args):
3120
+ """`gimp_inspect what=fonts`: installed font names, sorted, optionally narrowed by a
3121
+ case-insensitive substring `filter`, capped (`total` counts every match)."""
3122
+ flt = args.get('filter')
3123
+ if flt is not None and not isinstance(flt, str):
3124
+ raise ValueError('filter must be a string')
3125
+ installed = list(_font_list())
3126
+ names, total = lib.list_fonts(installed, flt)
3127
+ return {'fonts': names, 'total': total, 'returned': len(names), 'truncated': len(names) < total,
3128
+ 'default': lib.pick_default_font(installed)}
3129
+
3130
+
3131
+ def _image_ppi(img):
3132
+ ok, _xres, yres = img.get_resolution()
3133
+ return yres if ok else None
3134
+
3135
+
3136
+ def _text_font_size_pt(layer):
3137
+ size, unit = layer.get_font_size()
3138
+ return lib.unit_size_to_pt(
3139
+ size, unit.get_id() == Gimp.Unit.pixel().get_id(), unit.get_factor(),
3140
+ _image_ppi(layer.get_image()),
3141
+ )
3142
+
3143
+
3144
+ def _text_color_rgb(layer):
3145
+ r, g, b, _a = layer.get_color().get_rgba()
3146
+ return {'red': lib.linear_to_srgb_u8(r), 'green': lib.linear_to_srgb_u8(g),
3147
+ 'blue': lib.linear_to_srgb_u8(b)}
3148
+
3149
+
3150
+ def _has_markup(layer):
3151
+ """Whether the layer's text is stored as markup (per-character styling from the GUI), in
3152
+ which case it has no plain text."""
3153
+ return bool(layer.get_markup())
3154
+
3155
+
3156
+ def _text_fields(layer):
3157
+ """A text layer's own state, in gimp_text's units (points, 0-255 colour, ps_text-style
3158
+ alignment names). Shared by `describe`'s layer nodes and gimp_text's results. The text is
3159
+ capped for reporting (`text_length` is the full length); a markup layer reports '' with
3160
+ `has_markup`."""
3161
+ font = layer.get_font()
3162
+ has_markup = _has_markup(layer)
3163
+ text, length, truncated = lib.text_for_report('' if has_markup else layer.get_text())
3164
+ return {
3165
+ 'text': text,
3166
+ 'text_length': length,
3167
+ 'text_truncated': truncated,
3168
+ 'has_markup': has_markup,
3169
+ 'font': font.get_name() if font is not None else None,
3170
+ 'font_size': _text_font_size_pt(layer),
3171
+ 'color': _text_color_rgb(layer),
3172
+ 'alignment': lib.alignment_name(layer.get_justification().value_nick),
3173
+ }
3174
+
3175
+
3176
+ def _text_record(layer):
3177
+ ok, off_x, off_y = layer.get_offsets()
3178
+ record = {'layer_id': layer.get_id(), 'name': layer.get_name()}
3179
+ record.update(_text_fields(layer))
3180
+ ppi = _image_ppi(layer.get_image())
3181
+ record['ppi'] = ppi
3182
+ record['font_size_px'] = round(_text_size_px(layer), 1)
3183
+ record['bounds'] = {
3184
+ 'x': off_x if ok else None, 'y': off_y if ok else None,
3185
+ 'width': layer.get_width(), 'height': layer.get_height(),
3186
+ }
3187
+ return record
3188
+
3189
+
3190
+ def _set_text_color(layer, rgb):
3191
+ color = Gegl.Color.new('black')
3192
+ color.set_rgba(lib.srgb_u8_to_linear(rgb[0]), lib.srgb_u8_to_linear(rgb[1]),
3193
+ lib.srgb_u8_to_linear(rgb[2]), 1.0)
3194
+ layer.set_color(color)
3195
+
3196
+
3197
+ _TEXT_PROBE_PX = 8.0
3198
+
3199
+
3200
+ def _check_estimated_text_size(text, font, size_px, img, layer=None):
3201
+ """Refuse text that would render past the size cap BEFORE rendering it at full size: lay it
3202
+ out once on a throwaway layer at a tiny size and scale the measured extent up (with headroom,
3203
+ plus `layer`'s own letter/line spacing and indent, which do not scale), so an absurd request
3204
+ never makes GIMP allocate and draw an enormous layer just to be refused. Fails closed: when
3205
+ no estimate can be made, the request is refused."""
3206
+ if font is None:
3207
+ font = _resolved_font(_font_list(), None)
3208
+ probe = Gimp.TextLayer.new(img, text, font, _TEXT_PROBE_PX, Gimp.Unit.pixel())
3209
+ if probe is None:
3210
+ raise lib.OpError('gimp_op_failed', 'GIMP could not lay out the text to check its size')
3211
+ try:
3212
+ spacing = {}
3213
+ if layer is not None:
3214
+ spacing = {
3215
+ 'letter_spacing': layer.get_letter_spacing(),
3216
+ 'line_spacing': layer.get_line_spacing(),
3217
+ 'indent': layer.get_indent(),
3218
+ }
3219
+ width, height = lib.estimate_text_extent(
3220
+ probe.get_width(), probe.get_height(), _TEXT_PROBE_PX, size_px,
3221
+ chars=len(text), lines=text.count('\n') + 1, **spacing
3222
+ )
3223
+ finally:
3224
+ probe.delete()
3225
+ lib.check_estimated_text_size(width, height)
3226
+
3227
+
3228
+ def _text_size_px(layer):
3229
+ """The layer's font size in pixels, whichever unit it is stored in."""
3230
+ size, unit = layer.get_font_size()
3231
+ if unit.get_id() == Gimp.Unit.pixel().get_id():
3232
+ return size
3233
+ return lib.pt_to_px(_text_font_size_pt(layer), _image_ppi(layer.get_image()))
3234
+
3235
+
3236
+ def _text_state(layer):
3237
+ return {
3238
+ 'text': layer.get_text(), 'font': layer.get_font(), 'size': layer.get_font_size(),
3239
+ 'color': layer.get_color(), 'justification': layer.get_justification(),
3240
+ }
3241
+
3242
+
3243
+ def _same_size(a, b):
3244
+ return a[0] == b[0] and a[1].get_id() == b[1].get_id()
3245
+
3246
+
3247
+ def _text_restore(layer, state):
3248
+ """Best-effort rollback to a `_text_state` snapshot, touching only what changed (each setter
3249
+ re-renders the layer). When the size grew it is restored first, so the font and text go back
3250
+ at the smaller size. Never raises (it runs while the original failure is propagating)."""
3251
+ try:
3252
+ current_font = layer.get_font()
3253
+ size_changed = not _same_size(layer.get_font_size(), state['size'])
3254
+ shrink_first = size_changed and _text_size_px(layer) > _size_px_of(layer, state['size'])
3255
+ if shrink_first:
3256
+ layer.set_font_size(state['size'][0], state['size'][1])
3257
+ if state['font'] is not None and (
3258
+ current_font is None or current_font.get_name() != state['font'].get_name()
3259
+ ):
3260
+ layer.set_font(state['font'])
3261
+ if state['text'] is not None and layer.get_text() != state['text']:
3262
+ layer.set_text(state['text'])
3263
+ if size_changed and not shrink_first:
3264
+ layer.set_font_size(state['size'][0], state['size'][1])
3265
+ layer.set_color(state['color'])
3266
+ if layer.get_justification() != state['justification']:
3267
+ layer.set_justification(state['justification'])
3268
+ except Exception:
3269
+ pass
3270
+
3271
+
3272
+ def _size_px_of(layer, size):
3273
+ """A stored (value, unit) font size in pixels."""
3274
+ value, unit = size
3275
+ if unit.get_id() == Gimp.Unit.pixel().get_id():
3276
+ return value
3277
+ pt = lib.unit_size_to_pt(value, False, unit.get_factor(), _image_ppi(layer.get_image()))
3278
+ return lib.pt_to_px(pt, _image_ppi(layer.get_image()))
3279
+
3280
+
3281
+ def _resolved_font(fonts, font_name):
3282
+ if font_name is None:
3283
+ return fonts[lib.pick_default_font(list(fonts))]
3284
+ name, _how = lib.resolve_font(font_name, list(fonts))
3285
+ return fonts[name]
3286
+
3287
+
3288
+ def _op_text_create(img, args):
3289
+ text = lib.validate_text_content(lib.require(args, 'text'))
3290
+ size_pt = lib.validate_font_size_pt(args.get('font_size', lib.TEXT_DEFAULT_FONT_PT))
3291
+ x = int(args.get('x', 100))
3292
+ y = int(args.get('y', 100))
3293
+ _validated_move_offset(img, x, y)
3294
+ rgb = lib.validate_text_rgb(args)
3295
+ justification = None
3296
+ if args.get('alignment') is not None:
3297
+ justification = _JUSTIFICATIONS[lib.justification_nick(args['alignment'])]
3298
+ _layer_type_for(img) # refuses image modes a text layer cannot be added to (indexed)
3299
+ font = _resolved_font(_font_list(), args.get('font_name'))
3300
+ size_px = lib.pt_to_px(size_pt, _image_ppi(img))
3301
+ _check_estimated_text_size(text, font, size_px, img)
3302
+ layer = Gimp.TextLayer.new(img, text, font, size_px, Gimp.Unit.pixel())
3303
+ if layer is None:
3304
+ raise lib.OpError('gimp_op_failed', 'GIMP could not create the text layer')
3305
+ try:
3306
+ img.insert_layer(layer, None, 0)
3307
+ _assert_layer_attached(img, layer, 'the new text layer')
3308
+ # Colour and justification only take effect once the layer is in the image.
3309
+ if rgb is not None:
3310
+ _set_text_color(layer, rgb)
3311
+ if justification is not None:
3312
+ layer.set_justification(justification)
3313
+ lib.check_text_layer_size(layer.get_width(), layer.get_height())
3314
+ layer.set_offsets(x, y)
3315
+ record = _text_record(layer)
3316
+ except Exception:
3317
+ _discard_layer(img, layer)
3318
+ raise
3319
+ finally:
3320
+ _drop_proxies(img.get_id())
3321
+ return record
3322
+
3323
+
3324
+ def _text_target(img, args):
3325
+ layer = _layer(img, args)
3326
+ if not layer.is_text_layer():
3327
+ raise ValueError(
3328
+ 'layer %r is not a text layer; address a text layer by layer_id (gimp_inspect '
3329
+ 'what=layers flags them with is_text_layer)' % layer.get_name()
3330
+ )
3331
+ if _has_markup(layer):
3332
+ raise ValueError(
3333
+ 'layer %r has per-character styling (markup), which gimp_text cannot edit without '
3334
+ 'losing it; create a new text layer instead' % layer.get_name()
3335
+ )
3336
+ return layer
3337
+
3338
+
3339
+ def _op_text_edit(img, args, apply_fn, precheck_fn=None):
3340
+ """Run `apply_fn(layer)` on the targeted text layer. `precheck_fn(layer)` runs first, before
3341
+ anything changes, so a refusal there costs no re-render. On any failure in `apply_fn`, or if
3342
+ the re-rendered layer is past the size cap, restore the previous values and re-raise."""
3343
+ layer = _text_target(img, args)
3344
+ if precheck_fn is not None:
3345
+ precheck_fn(layer)
3346
+ state = _text_state(layer)
3347
+ try:
3348
+ apply_fn(layer)
3349
+ lib.check_text_layer_size(layer.get_width(), layer.get_height())
3350
+ except Exception:
3351
+ _text_restore(layer, state)
3352
+ raise
3353
+ finally:
3354
+ _drop_proxies(img.get_id())
3355
+ return _text_record(layer)
3356
+
3357
+
3358
+ def _op_text_set_content(img, args):
3359
+ text = lib.validate_text_content(lib.require(args, 'text'))
3360
+ return _op_text_edit(
3361
+ img, args, lambda layer: layer.set_text(text),
3362
+ precheck_fn=lambda layer: _check_estimated_text_size(
3363
+ text, layer.get_font(), _text_size_px(layer), img, layer),
3364
+ )
3365
+
3366
+
3367
+ def _op_text_set_font(img, args):
3368
+ font_name = lib.require(args, 'font_name')
3369
+ size_pt = lib.validate_font_size_pt(args['font_size']) if args.get('font_size') is not None else None
3370
+ font = _resolved_font(_font_list(), font_name)
3371
+ new_size_px = lib.pt_to_px(size_pt, _image_ppi(img)) if size_pt is not None else None
3372
+
3373
+ def precheck_fn(layer):
3374
+ size_px = new_size_px if new_size_px is not None else _text_size_px(layer)
3375
+ _check_estimated_text_size(layer.get_text(), font, size_px, img, layer)
3376
+
3377
+ def apply_fn(layer):
3378
+ # Shrinking: size first, so the new font never renders at the larger old size.
3379
+ if new_size_px is not None and new_size_px < _text_size_px(layer):
3380
+ layer.set_font_size(new_size_px, Gimp.Unit.pixel())
3381
+ layer.set_font(font)
3382
+ else:
3383
+ layer.set_font(font)
3384
+ if new_size_px is not None:
3385
+ layer.set_font_size(new_size_px, Gimp.Unit.pixel())
3386
+
3387
+ return _op_text_edit(img, args, apply_fn, precheck_fn=precheck_fn)
3388
+
3389
+
3390
+ def _op_text_set_color(img, args):
3391
+ rgb = lib.validate_text_rgb(args)
3392
+ if rgb is None:
3393
+ raise ValueError('red, green and blue are required')
3394
+ return _op_text_edit(img, args, lambda layer: _set_text_color(layer, rgb))
3395
+
3396
+
3397
+ def _op_text_set_alignment(img, args):
3398
+ justification = _JUSTIFICATIONS[lib.justification_nick(lib.require(args, 'alignment'))]
3399
+ return _op_text_edit(img, args, lambda layer: layer.set_justification(justification))
3400
+
3401
+
3402
+ TEXT_OPS = {
3403
+ 'create': _op_text_create,
3404
+ 'set_content': _op_text_set_content,
3405
+ 'set_font': _op_text_set_font,
3406
+ 'set_color': _op_text_set_color,
3407
+ 'set_alignment': _op_text_set_alignment,
3408
+ }
3409
+
3410
+
3411
+ def op_text(args):
3412
+ img = _image(args)
3413
+ top = args.get('op')
3414
+ fn = TEXT_OPS.get(top)
3415
+ if fn is None:
3416
+ raise ValueError('op must be one of %s' % sorted(TEXT_OPS))
3417
+ return fn(img, args)
3418
+
3419
+
2623
3420
  # ---- gimp_bake --------------------------------------------------------------------------------
2624
3421
 
2625
3422
 
@@ -2732,8 +3529,9 @@ def op_bake(args):
2732
3529
  # - `Item.scale(width, height, local_origin)` resizes just that one layer; called here while its
2733
3530
  # offset is still whatever `file_load_layer` gave it (verified: local_origin's own effect on
2734
3531
  # the resulting offset only matters when the pre-scale offset is non-zero), and the caller's
2735
- # `x`/`y` are applied afterward via the same absolute `set_offsets` `gimp_layer op=move` uses --
2736
- # so the requested position is always exact regardless of local_origin's own math. Both
3532
+ # `x`/`y` are applied afterward via the same absolute `set_offsets` call every layer-move op
3533
+ # in this bridge uses -- so the requested position is always exact regardless of
3534
+ # local_origin's own math. Both
2737
3535
  # `Drawable.scale()` and `Drawable.fill()` report failure by returning `False` rather than
2738
3536
  # raising (the same class `op_open`'s own `convert_precision` comment documents) -- checked
2739
3537
  # everywhere this file calls either, including the pre-existing `_fill_new_layer` shared with
@@ -2846,8 +3644,13 @@ def op_place_image(args):
2846
3644
  original_selection = img.get_selected_layers()
2847
3645
  baked_filters = []
2848
3646
  try:
3647
+ # The DoS floor applies to the SOURCE file's own size too; checked before any rotation
3648
+ # (it is symmetric in width and height, so the upright size passes or fails alike).
3649
+ lib.validate_resize_dims(loaded.get_width(), loaded.get_height())
3650
+ orientation_applied, _md = _file_orientation(path, ext)
3651
+ if orientation_applied is not None:
3652
+ _upright_placed_layer(loaded, orientation_applied)
2849
3653
  w0, h0 = loaded.get_width(), loaded.get_height()
2850
- lib.validate_resize_dims(w0, h0) # the DoS floor applies to the SOURCE file's own size too
2851
3654
 
2852
3655
  width, height = args.get('width'), args.get('height')
2853
3656
  if width is not None or height is not None:
@@ -2912,6 +3715,8 @@ def op_place_image(args):
2912
3715
  }
2913
3716
  if baked_filters:
2914
3717
  result['baked_filters'] = baked_filters
3718
+ if orientation_applied is not None:
3719
+ result['orientation_applied'] = orientation_applied
2915
3720
  return result
2916
3721
 
2917
3722
 
@@ -3045,11 +3850,1034 @@ def op_convert_image_mode(args):
3045
3850
  return {'mode': mode, 'converted': True}
3046
3851
 
3047
3852
 
3853
+ # ---- gimp_transform_layer ---------------------------------------------------------------------
3854
+ #
3855
+ # A per-LAYER affine transform (fit/scale/move/rotate/flip/skew/free) -- distinct from the
3856
+ # whole-CANVAS geometry ops above (crop/resize/rotate/flip), which move or resize every layer
3857
+ # together. Addressed the same way every other gimp_* layer op is (`_layer`): layer_id takes
3858
+ # priority over layer (name), neither given falls back to the selected layer, or the topmost one.
3859
+ #
3860
+ # Verified live (GIMP 3.2.6), the assumptions this section is built on:
3861
+ # - `Item.transform_scale`/`transform_rotate`/`transform_flip_simple`/`transform_matrix` all
3862
+ # carry the layer's own mask along automatically, repositioned and resized to match the
3863
+ # layer's new bounds exactly -- no separate mask transform is needed.
3864
+ # - Calling any of these on a GROUP layer transforms the whole group as a unit: every
3865
+ # descendant's own offsets and size update too, in lockstep with the group's. No special-
3866
+ # casing is needed for a group target beyond its own alpha (every non-group descendant gets
3867
+ # one independently -- a group has no pixels of its own) and its predicted size caps: the
3868
+ # group's own bounding box, each non-group descendant's own predicted size, and the SUM of
3869
+ # those descendant sizes must all fit (GIMP allocates and transforms each descendant's own
3870
+ # pixel buffer independently, so the group's bounding box fitting the cap guarantees
3871
+ # neither a single descendant's buffer nor their combined memory does). A locked layer
3872
+ # anywhere in the group's subtree refuses the whole transform up front.
3873
+ # - `Context.transform_resize` set to ADJUST (rather than the default CLIP) is what makes
3874
+ # `rotate`/`skew`/`free` grow the layer's own bounding box to its new, larger extent instead
3875
+ # of clipping corners that rotate/shear outside the old one -- the same growth
3876
+ # `gimp_transform_canvas`'s `expand` gives the whole canvas, but always-on here since a
3877
+ # layer (unlike the canvas) has no reason to ever clip its own content.
3878
+ # - `Context.interpolation` is read by the transform itself, not passed as an argument to any
3879
+ # of the methods above -- set explicitly before every call (never left at whatever a PRIOR
3880
+ # call happened to leave it at).
3881
+ # - A layer with no alpha channel (e.g. a flattened base layer) would otherwise fill any area
3882
+ # a transform exposes outside its old bounds with an opaque background color rather than
3883
+ # transparency -- `add_alpha()` first avoids that, for every op, not only ones that grow the
3884
+ # bounding box (harmless on one that doesn't need it).
3885
+ # - `set_offsets`/every `transform_*` method reports a lock-position or lock-content layer it
3886
+ # could not transform by returning a FALSY value (`False`/`None`) rather than raising --
3887
+ # checked both proactively (`_refuse_if_layer_locked`, before alpha is ever added) and on the
3888
+ # actual call's own return value (`_checked_transform`, defense in depth).
3889
+ # - A text layer stays a text layer through every op here (verified live for scale, rotate,
3890
+ # flip, skew and free; reported back as `text_layer`), never rasterized.
3891
+ # - Refuses outright when the layer (or a containing group) carries a masked or unverifiable
3892
+ # adjustment filter -- `_refuse_if_masked_filters_on` (the same scoped check the whole-image
3893
+ # geometry ops' own `_refuse_if_masked_filters` is a sibling of). An UNMASKED adjustment
3894
+ # filter, or an unmasked position/direction-dependent EFFECT filter (vignette, motion_blur,
3895
+ # drop_shadow) under flip, an exact 90-degree-multiple rotate, or a uniform scale, is instead
3896
+ # REMAPPED the same way the canvas geometry ops remap it (`_snapshot_effect_transform`,
3897
+ # scoped to this layer's own subtree); an arbitrary-angle rotate, any skew or free transform,
3898
+ # or a non-uniform scale is refused outright while such an effect is present, the same way
3899
+ # the canvas ops refuse an arbitrary-angle rotate.
3900
+
3901
+
3902
+ def _transform_layer_center(layer):
3903
+ ok, x, y = layer.get_offsets()
3904
+ return (x if ok else 0) + layer.get_width() / 2.0, (y if ok else 0) + layer.get_height() / 2.0
3905
+
3906
+
3907
+ def _transform_layer_bounds(layer):
3908
+ ok, x, y = layer.get_offsets()
3909
+ return {
3910
+ 'x': x if ok else None, 'y': y if ok else None,
3911
+ 'width': layer.get_width(), 'height': layer.get_height(),
3912
+ }
3913
+
3914
+
3915
+ def _set_transform_layer_interpolation(args):
3916
+ choice = lib.validate_choice(
3917
+ 'interpolation', args.get('interpolation', 'cubic'), lib.TRANSFORM_LAYER_INTERPOLATIONS
3918
+ )
3919
+ Gimp.context_set_interpolation(_INTERPOLATION_ENUM[choice])
3920
+ return choice
3921
+
3922
+
3923
+ def _snap_px(value):
3924
+ """Round-half-up to the nearest whole pixel -- `floor(v + 0.5)`, not Python's own banker's
3925
+ `round()` (which rounds a .5 to the nearest EVEN integer, silently snapping e.g. 2.5 down to
3926
+ 2). Every target rectangle `fit`/`scale` computes, and every `move` target, snaps to an
3927
+ integer pixel this same, predictable way, so a layer already exactly sized/positioned for the
3928
+ request is a true no-op -- never a half-pixel resample from float drift alone."""
3929
+ return int(math.floor(value + 0.5))
3930
+
3931
+
3932
+ def _refuse_if_layer_locked(layer):
3933
+ """Refuses outright, before anything else runs (including adding alpha), when `layer` -- or,
3934
+ for a group, any layer in its subtree that the transform would move -- is lock-position or
3935
+ lock-content. Verified live (GIMP 3.2.6): GIMP does not raise for either -- it silently
3936
+ no-ops `set_offsets`/every `transform_*` method instead (`_checked_transform`'s own comment)
3937
+ -- so this catches it earlier, naming the offending layer, rather than leave a group
3938
+ half-transformed or fall through to that generic defense-in-depth check alone."""
3939
+ for item in _layer_subtree(layer):
3940
+ if item.get_lock_position():
3941
+ raise ValueError(
3942
+ 'layer %r is lock-position and cannot be transformed; unlock it first'
3943
+ % item.get_name()
3944
+ )
3945
+ if item.get_lock_content():
3946
+ raise ValueError(
3947
+ 'layer %r is lock-content and cannot be transformed; unlock it first'
3948
+ % item.get_name()
3949
+ )
3950
+
3951
+
3952
+ def _checked_transform(layer, result):
3953
+ """Verifies a `Item.transform_*`/`set_offsets` call's own return value actually did what it
3954
+ claims. GIMP reports a layer it could not transform (lock-position, lock-content, or any
3955
+ other reason `_refuse_if_layer_locked`'s own proactive check didn't already catch) by
3956
+ returning a FALSY value instead of raising -- `False` for `set_offsets`, `None` for every
3957
+ `transform_*` method (verified live, GIMP 3.2.6) -- so a caller that trusts the call
3958
+ unconditionally would silently report success (and, worse, alpha_added: true) for a
3959
+ transform that never actually happened. `result is True` (`set_offsets`' own success value)
3960
+ is accepted without an id to compare; anything else truthy must be the SAME item this call
3961
+ targeted, not some other one GIMP happened to return."""
3962
+ if not result:
3963
+ raise lib.OpError(
3964
+ 'gimp_op_failed',
3965
+ 'GIMP did not transform layer %r -- it may be lock-position or lock-content'
3966
+ % layer.get_name()
3967
+ )
3968
+ if result is not True and result.get_id() != layer.get_id():
3969
+ raise lib.OpError(
3970
+ 'gimp_op_failed', 'GIMP transformed a different item than layer %r' % layer.get_name()
3971
+ )
3972
+
3973
+
3974
+ def _ensure_layer_alpha(layer):
3975
+ """Adds an alpha channel to `layer` if it doesn't have one already. For a GROUP target, adds
3976
+ one to every NON-GROUP descendant that lacks it instead (a group has no pixels of its own to
3977
+ add a channel to, but transforming it cascades to every descendant, each of which can expose
3978
+ a new area the same way a plain layer target can). Returns whether alpha was actually added
3979
+ anywhere."""
3980
+ if layer.is_group():
3981
+ added = False
3982
+ for descendant in _layer_subtree(layer):
3983
+ if descendant.get_id() == layer.get_id() or descendant.is_group():
3984
+ continue
3985
+ if descendant.has_alpha():
3986
+ continue
3987
+ if not descendant.add_alpha():
3988
+ raise lib.OpError(
3989
+ 'gimp_op_failed',
3990
+ 'GIMP could not add an alpha channel to layer %r' % descendant.get_name()
3991
+ )
3992
+ added = True
3993
+ return added
3994
+ if layer.has_alpha():
3995
+ return False
3996
+ if not layer.add_alpha():
3997
+ raise lib.OpError('gimp_op_failed', 'GIMP could not add an alpha channel to this layer')
3998
+ return True
3999
+
4000
+
4001
+ def _tracked_effect_names_on(img, layer):
4002
+ """Live, ledgered position/direction-dependent EFFECT filter names (vignette, motion_blur,
4003
+ drop_shadow -- `ROTATE_DEPENDENT_OPERATIONS`) anywhere in `layer`'s own subtree (itself, or
4004
+ every descendant for a group target) -- NOT its ancestors: an ancestor group's own filter is
4005
+ defined relative to the ANCESTOR's own extent, which transforming a descendant inside it
4006
+ never changes, so it needs neither remapping nor refusing here (unlike a MASKED adjustment
4007
+ filter's mask-channel alignment, which does -- see `_refuse_if_masked_filters_on`)."""
4008
+ subtree = _layer_subtree(layer)
4009
+ live_names = {f.get_name() for l in subtree for f in l.get_filters()}
4010
+ filters, _unknown = _ledger_get(img)
4011
+ return sorted(
4012
+ name for name in live_names
4013
+ if filters.get(name, {}).get('operation') in ROTATE_DEPENDENT_OPERATIONS
4014
+ )
4015
+
4016
+
4017
+ def _refuse_if_tracked_effects_on(img, op_name, layer, reason):
4018
+ tracked = _tracked_effect_names_on(img, layer)
4019
+ if tracked:
4020
+ raise ValueError(
4021
+ '%s cannot proceed while effect filter(s) %s are present on this layer (or a group '
4022
+ 'containing it): %s. Delete these filters first and re-add them afterwards.'
4023
+ % (op_name, ', '.join(repr(n) for n in tracked), reason)
4024
+ )
4025
+
4026
+
4027
+ def _refuse_if_non_right_angle_tracked_effects_on(img, degrees, op_name, layer):
4028
+ if lib.is_right_angle_degrees(degrees):
4029
+ return
4030
+ tracked = _tracked_effect_names_on(img, layer)
4031
+ if tracked:
4032
+ raise ValueError(
4033
+ '%s cannot use an arbitrary angle (%.4g°) while effect filter(s) %s are present on '
4034
+ 'this layer (or a group containing it): only an exact 0/90/180/270-degree rotation '
4035
+ 'keeps them locked to the content. Rotate at a right angle instead, or delete these '
4036
+ 'filters first and re-add them afterwards.'
4037
+ % (op_name, degrees, ', '.join(repr(n) for n in tracked))
4038
+ )
4039
+
4040
+
4041
+ def _transform_layer_precision(img):
4042
+ return lib.transform_layer_precision_bucket(img.get_precision().value_nick)
4043
+
4044
+
4045
+ def _validate_transform_sizes(img, layer, matrix, precision):
4046
+ """Validates `layer`'s own PREDICTED size (via `lib.transformed_bounds`, mapping the
4047
+ layer's current rect through `matrix`) against the engine's precision-aware size cap
4048
+ (`lib.validate_document_dims`, the same one gimp_create_document uses) -- and, for a GROUP
4049
+ target, every non-group descendant's own predicted size the same way, plus the descendants'
4050
+ summed pixel count (`lib.validate_group_total_pixels`; see this section's own comment). Does NOT validate the predicted ORIGIN -- callers whose own target position
4051
+ is exactly `transformed_bounds`' own (new_x, new_y) call `_validated_move_offset` on it
4052
+ themselves (`_validate_transform_prediction`, below); `fit`'s own target is the canvas
4053
+ center instead, validated separately. Returns the layer's own predicted
4054
+ (new_x, new_y, new_w, new_h)."""
4055
+ ok, ox, oy = layer.get_offsets()
4056
+ ox, oy = (ox if ok else 0), (oy if ok else 0)
4057
+ w, h = layer.get_width(), layer.get_height()
4058
+ new_x, new_y, new_w, new_h = lib.transformed_bounds(matrix, ox, oy, w, h)
4059
+ lib.validate_document_dims(
4060
+ lib.ceil_with_margin(new_w), lib.ceil_with_margin(new_h), precision=precision
4061
+ )
4062
+ if layer.is_group():
4063
+ sizes = []
4064
+ for descendant in _layer_subtree(layer):
4065
+ if descendant.get_id() == layer.get_id() or descendant.is_group():
4066
+ continue
4067
+ dok, dx, dy = descendant.get_offsets()
4068
+ dx, dy = (dx if dok else 0), (dy if dok else 0)
4069
+ dw, dh = descendant.get_width(), descendant.get_height()
4070
+ _, _, pred_w, pred_h = lib.transformed_bounds(matrix, dx, dy, dw, dh)
4071
+ pw, ph = lib.ceil_with_margin(pred_w), lib.ceil_with_margin(pred_h)
4072
+ lib.validate_document_dims(pw, ph, precision=precision)
4073
+ sizes.append((pw, ph))
4074
+ lib.validate_group_total_pixels(sizes, precision)
4075
+ return new_x, new_y, new_w, new_h
4076
+
4077
+
4078
+ def _validate_transform_prediction(img, layer, matrix, precision):
4079
+ """`_validate_transform_sizes` plus the same predicted-ORIGIN bound `move` enforces
4080
+ (`_validated_move_offset`) -- every op whose own target position IS `transformed_bounds`'
4081
+ predicted (new_x, new_y): rotate, skew, free, and scale (`fit`'s own target is the canvas
4082
+ center instead; see its own handler)."""
4083
+ new_x, new_y, new_w, new_h = _validate_transform_sizes(img, layer, matrix, precision)
4084
+ _validated_move_offset(img, new_x, new_y)
4085
+ return new_x, new_y, new_w, new_h
4086
+
4087
+
4088
+ def _transform_layer_fit(layer, img, args):
4089
+ mode = lib.validate_choice('mode', args.get('mode', 'fit'), ('fit', 'fill'))
4090
+ w, h = layer.get_width(), layer.get_height()
4091
+ fraction = lib.fit_scale_fraction(w, h, img.get_width(), img.get_height(), mode)
4092
+ if abs(fraction - 1.0) > 1e-9:
4093
+ _refuse_if_tracked_effects_on(
4094
+ img, 'fit', layer, 'fit/fill cannot keep them locked to the content at this scale'
4095
+ )
4096
+ cx, cy = _transform_layer_center(layer)
4097
+ matrix = lib.compose_layer_matrix(cx, cy, fraction * 100.0, fraction * 100.0, 0.0, 0.0, 0.0, 0.0, 0.0)
4098
+ precision = _transform_layer_precision(img)
4099
+ # fit's own target is centered on the CANVAS, not wherever a scale-about-its-own-center
4100
+ # would leave it -- only `matrix`'s predicted SIZE is used below; its predicted origin is
4101
+ # irrelevant and is never read.
4102
+ _, _, new_w, new_h = _validate_transform_sizes(img, layer, matrix, precision)
4103
+ new_w_i, new_h_i = _snap_px(new_w), _snap_px(new_h)
4104
+ lib.validate_transformed_layer_size(new_w_i, new_h_i)
4105
+ target_x = (img.get_width() - new_w_i) / 2.0
4106
+ target_y = (img.get_height() - new_h_i) / 2.0
4107
+ _validated_move_offset(img, target_x, target_y)
4108
+ planned = _snapshot_effect_transform(
4109
+ img, 'fit',
4110
+ lambda operation, params, flayer: lib.resize_effect_params(operation, params, fraction, fraction),
4111
+ layers=_layer_subtree(layer),
4112
+ )
4113
+ alpha_added = _ensure_layer_alpha(layer)
4114
+ x0, y0 = _snap_px(target_x), _snap_px(target_y)
4115
+ result = layer.transform_scale(float(x0), float(y0), float(x0 + new_w_i), float(y0 + new_h_i))
4116
+ _checked_transform(layer, result)
4117
+ failures = _apply_planned_effect_transform(img, planned)
4118
+ extra = {'mode': mode, 'scale_percent': round(fraction * 100.0, 6), 'alpha_added': alpha_added}
4119
+ if failures:
4120
+ extra['effect_update_failures'] = failures
4121
+ return extra
4122
+
4123
+
4124
+ def _transform_layer_scale(layer, img, args):
4125
+ lo, hi = lib.TRANSFORM_LAYER_MIN_SCALE_PERCENT, lib.TRANSFORM_LAYER_MAX_SCALE_PERCENT
4126
+ sp = args.get('scale_percent')
4127
+ sx_in, sy_in = args.get('scale_x_percent'), args.get('scale_y_percent')
4128
+ if sx_in is None and sy_in is None:
4129
+ if sp is None:
4130
+ raise ValueError('scale requires scale_percent, or scale_x_percent/scale_y_percent')
4131
+ sx = sy = lib.validate_range('scale_percent', sp, lo, hi)
4132
+ else:
4133
+ sx = lib.validate_range('scale_x_percent', sx_in if sx_in is not None else (sp if sp is not None else 100.0), lo, hi)
4134
+ sy = lib.validate_range('scale_y_percent', sy_in if sy_in is not None else (sp if sp is not None else 100.0), lo, hi)
4135
+ if abs(sx - sy) > 1e-9:
4136
+ _refuse_if_tracked_effects_on(
4137
+ img, 'scale', layer, 'a non-uniform scale cannot keep them locked to the content'
4138
+ )
4139
+ cx, cy = _transform_layer_center(layer)
4140
+ matrix = lib.compose_layer_matrix(cx, cy, sx, sy, 0.0, 0.0, 0.0, 0.0, 0.0)
4141
+ precision = _transform_layer_precision(img)
4142
+ new_x, new_y, new_w, new_h = _validate_transform_prediction(img, layer, matrix, precision)
4143
+ planned = _snapshot_effect_transform(
4144
+ img, 'scale',
4145
+ lambda operation, params, flayer: lib.resize_effect_params(operation, params, sx / 100.0, sy / 100.0),
4146
+ layers=_layer_subtree(layer),
4147
+ )
4148
+ alpha_added = _ensure_layer_alpha(layer)
4149
+ x0, y0 = _snap_px(new_x), _snap_px(new_y)
4150
+ x1, y1 = _snap_px(new_x + new_w), _snap_px(new_y + new_h)
4151
+ lib.validate_transformed_layer_size(x1 - x0, y1 - y0)
4152
+ result = layer.transform_scale(float(x0), float(y0), float(x1), float(y1))
4153
+ _checked_transform(layer, result)
4154
+ failures = _apply_planned_effect_transform(img, planned)
4155
+ extra = {'scale_x_percent': sx, 'scale_y_percent': sy, 'alpha_added': alpha_added}
4156
+ if failures:
4157
+ extra['effect_update_failures'] = failures
4158
+ return extra
4159
+
4160
+
4161
+ def _transform_layer_move(layer, img, args):
4162
+ delta, absolute, center_on = args.get('delta'), args.get('absolute'), args.get('center_on')
4163
+ given = [m for m in (delta, absolute, center_on) if m is not None]
4164
+ if len(given) == 0:
4165
+ raise ValueError(
4166
+ 'move requires exactly one of delta, absolute, center_on -- each a {x, y} object; '
4167
+ 'flat fields like delta_x/absolute_x are not accepted'
4168
+ )
4169
+ if len(given) > 1:
4170
+ raise ValueError(
4171
+ 'move accepts only ONE of delta, absolute, center_on -- not more than one (each a '
4172
+ '{x, y} object; flat fields like delta_x/absolute_x are not accepted)'
4173
+ )
4174
+ ok, ox, oy = layer.get_offsets()
4175
+ ox, oy = (ox if ok else 0), (oy if ok else 0)
4176
+ w, h = layer.get_width(), layer.get_height()
4177
+ if delta is not None:
4178
+ nx, ny = ox + float(lib.require(delta, 'x')), oy + float(lib.require(delta, 'y'))
4179
+ elif absolute is not None:
4180
+ nx, ny = float(lib.require(absolute, 'x')), float(lib.require(absolute, 'y'))
4181
+ else:
4182
+ nx = float(lib.require(center_on, 'x')) - w / 2.0
4183
+ ny = float(lib.require(center_on, 'y')) - h / 2.0
4184
+ _validated_move_offset(img, nx, ny)
4185
+ alpha_added = _ensure_layer_alpha(layer)
4186
+ result = layer.set_offsets(_snap_px(nx), _snap_px(ny))
4187
+ _checked_transform(layer, result)
4188
+ return {'alpha_added': alpha_added}
4189
+
4190
+
4191
+ def _transform_layer_rotate(layer, img, args):
4192
+ degrees = float(lib.require(args, 'degrees'))
4193
+ if not math.isfinite(degrees):
4194
+ raise ValueError('degrees must be finite')
4195
+ _refuse_if_non_right_angle_tracked_effects_on(img, degrees, 'rotate', layer)
4196
+ cx, cy = _transform_layer_center(layer)
4197
+ matrix = lib.compose_layer_matrix(cx, cy, 100.0, 100.0, degrees, 0.0, 0.0, 0.0, 0.0)
4198
+ precision = _transform_layer_precision(img)
4199
+ _validate_transform_prediction(img, layer, matrix, precision)
4200
+ planned = _snapshot_effect_transform(
4201
+ img, 'rotate',
4202
+ lambda operation, params, flayer: lib.rotate_effect_params(
4203
+ operation, params, degrees, flayer.get_width(), flayer.get_height()
4204
+ ),
4205
+ layers=_layer_subtree(layer),
4206
+ )
4207
+ alpha_added = _ensure_layer_alpha(layer)
4208
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
4209
+ result = layer.transform_rotate(math.radians(degrees), True, 0.0, 0.0)
4210
+ _checked_transform(layer, result)
4211
+ failures = _apply_planned_effect_transform(img, planned)
4212
+ extra = {'degrees': degrees, 'alpha_added': alpha_added}
4213
+ if failures:
4214
+ extra['effect_update_failures'] = failures
4215
+ return extra
4216
+
4217
+
4218
+ def _transform_layer_flip(layer, img, args):
4219
+ axis = args.get('axis')
4220
+ if axis not in _FLIP_ORIENTATIONS:
4221
+ raise ValueError('axis must be one of %s' % sorted(_FLIP_ORIENTATIONS))
4222
+ planned = _snapshot_effect_transform(
4223
+ img, 'flip',
4224
+ lambda operation, params, flayer: lib.flip_effect_params(operation, params, axis),
4225
+ layers=_layer_subtree(layer),
4226
+ )
4227
+ alpha_added = _ensure_layer_alpha(layer)
4228
+ result = layer.transform_flip_simple(_FLIP_ORIENTATIONS[axis], True, 0.0)
4229
+ _checked_transform(layer, result)
4230
+ failures = _apply_planned_effect_transform(img, planned)
4231
+ extra = {'axis': axis, 'alpha_added': alpha_added}
4232
+ if failures:
4233
+ extra['effect_update_failures'] = failures
4234
+ return extra
4235
+
4236
+
4237
+ def _transform_layer_skew(layer, img, args):
4238
+ skew_h, skew_v = args.get('skew_h_degrees'), args.get('skew_v_degrees')
4239
+ if skew_h is None and skew_v is None:
4240
+ raise ValueError('skew requires at least one of skew_h_degrees, skew_v_degrees')
4241
+ skew_h = float(skew_h) if skew_h is not None else 0.0
4242
+ skew_v = float(skew_v) if skew_v is not None else 0.0
4243
+ if not (math.isfinite(skew_h) and math.isfinite(skew_v)):
4244
+ raise ValueError('skew_h_degrees/skew_v_degrees must be finite')
4245
+ _refuse_if_tracked_effects_on(
4246
+ img, 'skew', layer, 'a skew cannot keep them locked to the content at any angle'
4247
+ )
4248
+ cx, cy = _transform_layer_center(layer)
4249
+ matrix = lib.compose_layer_matrix(cx, cy, 100.0, 100.0, 0.0, skew_h, skew_v, 0.0, 0.0)
4250
+ precision = _transform_layer_precision(img)
4251
+ _validate_transform_prediction(img, layer, matrix, precision)
4252
+ alpha_added = _ensure_layer_alpha(layer)
4253
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
4254
+ result = layer.transform_matrix(*matrix)
4255
+ _checked_transform(layer, result)
4256
+ return {'skew_h_degrees': skew_h, 'skew_v_degrees': skew_v, 'alpha_added': alpha_added}
4257
+
4258
+
4259
+ def _transform_layer_free(layer, img, args):
4260
+ lo, hi = lib.TRANSFORM_LAYER_MIN_SCALE_PERCENT, lib.TRANSFORM_LAYER_MAX_SCALE_PERCENT
4261
+ sx = lib.validate_range('scale_x_percent', args.get('scale_x_percent', 100.0), lo, hi)
4262
+ sy = lib.validate_range('scale_y_percent', args.get('scale_y_percent', 100.0), lo, hi)
4263
+ degrees = float(args.get('degrees', 0.0))
4264
+ off_lo, off_hi = -lib.TRANSFORM_LAYER_MAX_OFFSET_PX, lib.TRANSFORM_LAYER_MAX_OFFSET_PX
4265
+ offset_x = lib.validate_range('offset_x', args.get('offset_x', 0.0), off_lo, off_hi)
4266
+ offset_y = lib.validate_range('offset_y', args.get('offset_y', 0.0), off_lo, off_hi)
4267
+ if not math.isfinite(degrees):
4268
+ raise ValueError('degrees must be finite')
4269
+ _refuse_if_tracked_effects_on(
4270
+ img, 'free', layer, 'a combined free transform cannot keep them locked to the content'
4271
+ )
4272
+ cx, cy = _transform_layer_center(layer)
4273
+ matrix = lib.compose_layer_matrix(cx, cy, sx, sy, degrees, 0.0, 0.0, offset_x, offset_y)
4274
+ precision = _transform_layer_precision(img)
4275
+ _validate_transform_prediction(img, layer, matrix, precision)
4276
+ alpha_added = _ensure_layer_alpha(layer)
4277
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
4278
+ result = layer.transform_matrix(*matrix)
4279
+ _checked_transform(layer, result)
4280
+ return {
4281
+ 'scale_x_percent': sx, 'scale_y_percent': sy, 'degrees': degrees,
4282
+ 'offset_x': offset_x, 'offset_y': offset_y, 'alpha_added': alpha_added,
4283
+ }
4284
+
4285
+
4286
+ TRANSFORM_LAYER_OPS = {
4287
+ 'fit': _transform_layer_fit,
4288
+ 'scale': _transform_layer_scale,
4289
+ 'move': _transform_layer_move,
4290
+ 'rotate': _transform_layer_rotate,
4291
+ 'flip': _transform_layer_flip,
4292
+ 'skew': _transform_layer_skew,
4293
+ 'free': _transform_layer_free,
4294
+ }
4295
+
4296
+
4297
+ def op_transform_layer(args):
4298
+ img = _image(args)
4299
+ top = args.get('op')
4300
+ fn = TRANSFORM_LAYER_OPS.get(top)
4301
+ if fn is None:
4302
+ raise ValueError('op must be one of %s' % sorted(TRANSFORM_LAYER_OPS))
4303
+ lib.reject_foreign_transform_fields(top, args)
4304
+ layer = _layer(img, args)
4305
+ _refuse_if_masked_filters_on(img, top, layer)
4306
+ _refuse_if_layer_locked(layer)
4307
+ # Interpolation (and, for rotate/skew/free, the ADJUST transform-resize mode) are ambient
4308
+ # Context settings the transform methods below read rather than take as arguments -- pushed
4309
+ # and popped so this call's choice never leaks into a LATER, unrelated gimp_* call (the same
4310
+ # bracket `_fill_new_layer` uses around its own context changes). Each op validates its own
4311
+ # arguments, the predicted result, and any tracked effect in scope, and raises BEFORE calling
4312
+ # `_ensure_layer_alpha` (own code) or mutating anything, so a rejected call never adds an
4313
+ # alpha channel it then goes on to refuse.
4314
+ Gimp.context_push()
4315
+ try:
4316
+ interpolation = _set_transform_layer_interpolation(args)
4317
+ Gimp.Selection.none(img) # see op_open: geometry always applies to the whole layer
4318
+ extra = fn(layer, img, args)
4319
+ finally:
4320
+ Gimp.context_pop()
4321
+ _drop_proxies(img.get_id())
4322
+ result = {
4323
+ 'layer_id': layer.get_id(),
4324
+ 'bounds': _transform_layer_bounds(layer),
4325
+ 'interpolation': interpolation,
4326
+ 'text_layer': layer.is_text_layer(),
4327
+ }
4328
+ result.update(extra)
4329
+ return result
4330
+
4331
+
3048
4332
  def op_select_none(args):
3049
4333
  Gimp.Selection.none(_image(args))
3050
4334
  return {'selection': 'none'}
3051
4335
 
3052
4336
 
4337
+ # ---------- selection: gimp_select / gimp_modify_selection / gimp_layer_mask / masks ------------
4338
+
4339
+ _CHANNEL_OPS = {
4340
+ 'replace': Gimp.ChannelOps.REPLACE, 'add': Gimp.ChannelOps.ADD,
4341
+ 'subtract': Gimp.ChannelOps.SUBTRACT, 'intersect': Gimp.ChannelOps.INTERSECT,
4342
+ }
4343
+
4344
+
4345
+ def _selection_to_channel(img, name):
4346
+ """Copy the active selection into a (replaced) named channel, clear the selection, and return
4347
+ the op result every selection op reports."""
4348
+ w, h = img.get_width(), img.get_height()
4349
+ rect = Gegl.Rectangle.new(0, 0, w, h)
4350
+ data = img.get_selection().get_buffer().get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
4351
+ ch = _replace_named_channel(img, name, w, h)
4352
+ buf = ch.get_buffer()
4353
+ buf.set(rect, "Y' u8", data)
4354
+ buf.flush()
4355
+ ch.update(0, 0, w, h)
4356
+ Gimp.Selection.none(img)
4357
+ _drop_proxies(img.get_id())
4358
+ selected, fraction = _channel_coverage(ch, w, h)
4359
+ return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
4360
+
4361
+
4362
+ def _with_scratch_channel(img, w, h, fn):
4363
+ """Run `fn(scratch_channel)` against a throwaway channel inserted for the duration of the
4364
+ call, then remove it regardless of outcome -- including when `fn` itself raises partway
4365
+ through (e.g. a bad gradient argument), so a failed select never leaves a stray channel
4366
+ behind. Used to stash a selection's pixel data somewhere durable (a GIMP selection is a
4367
+ single, unnamed piece of image state -- building a SECOND shape while the first is still
4368
+ needed, as combine != 'replace' does, has nowhere else to put it).
4369
+
4370
+ Verified the same defensive way `_assert_layer_attached` checks a new layer: some GIMP calls
4371
+ report success having silently done nothing, so insertion is confirmed by walking the image's
4372
+ own live channel list rather than trusting the call's return value alone. If that check fails,
4373
+ there is nothing to remove (the channel was never actually attached). Restores the image's
4374
+ selected layers afterward -- see `_replace_named_channel`'s identical comment for why."""
4375
+ original_selection = img.get_selected_layers()
4376
+ scratch = Gimp.Channel.new(img, '__select_scratch__', w, h, 50.0, Gegl.Color.new('black'))
4377
+ img.insert_channel(scratch, None, 0)
4378
+ if not any(ch.get_id() == scratch.get_id() for ch in img.get_channels()):
4379
+ raise lib.OpError('gimp_op_failed', 'the scratch channel did not attach to the image')
4380
+ img.set_selected_layers(original_selection)
4381
+ try:
4382
+ return fn(scratch)
4383
+ finally:
4384
+ img.remove_channel(scratch)
4385
+ img.set_selected_layers(original_selection)
4386
+
4387
+
4388
+ def _capture_selection(img, w, h, scratch):
4389
+ """Copy the CURRENT active selection's pixels into `scratch`'s own buffer -- a durable
4390
+ snapshot the caller can reload as the active selection later, after building a SECOND shape
4391
+ (`_shape_selection`'s next call) would otherwise overwrite the first one."""
4392
+ data = img.get_selection().get_buffer().get(
4393
+ Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
4394
+ buf = scratch.get_buffer()
4395
+ buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", data)
4396
+ buf.flush()
4397
+ scratch.update(0, 0, w, h)
4398
+
4399
+
4400
+ def _shrink_edge_locked(img, px):
4401
+ """`Gimp.Selection.shrink` that does not erode from the canvas edge. GIMP's own shrink treats
4402
+ the area outside the canvas as unselected (its PDB call hardcodes edge-lock off), so a
4403
+ selection touching the edge lost a strip there. Shrinking is the inverse of growing the
4404
+ inverse, and a grow never reaches past the canvas, so shrink = invert, grow, invert."""
4405
+ Gimp.Selection.invert(img)
4406
+ Gimp.Selection.grow(img, px)
4407
+ Gimp.Selection.invert(img)
4408
+
4409
+
4410
+ def _color_arg(img, drawable, args, sample_merged):
4411
+ """The target color for mode=color_range: an explicit `color` hex, or the color AT a sample
4412
+ point (x, y), always in document pixels. `Gimp.Image.pick_color` takes document coordinates
4413
+ regardless of `sample_merged` and itself reports failure -- rather than a wrong or default
4414
+ pixel -- for a point outside the image, or (when not sampling the composite) outside the one
4415
+ given drawable's own bounds (verified live, GIMP 3.2.6: an out-of-bounds point returns
4416
+ `(False, None)`, never raises and never silently samples the wrong pixel)."""
4417
+ color = args.get('color')
4418
+ if color is not None:
4419
+ return Gegl.Color.new(lib.validate_hex_color('color', color))
4420
+ if args.get('x') is None or args.get('y') is None:
4421
+ raise ValueError('color_range needs `color` (e.g. "#c0392b") or a sample point x, y')
4422
+ x, y = float(args['x']), float(args['y'])
4423
+ success, picked = img.pick_color([drawable], x, y, sample_merged, False, 0.0)
4424
+ if not success or picked is None:
4425
+ where = 'the image' if sample_merged else 'layer %r' % drawable.get_name()
4426
+ raise ValueError('sample point (%g, %g) is outside %s' % (x, y, where))
4427
+ return picked
4428
+
4429
+
4430
+ def _layer_local_point(drawable, x, y):
4431
+ """Convert a DOCUMENT-space point to coordinates local to `drawable`, refusing one outside its
4432
+ bounds. `Gimp.Image.select_contiguous_color` (unlike `pick_color`) takes LOCAL coordinates when
4433
+ sampling one drawable rather than the composite -- verified live, GIMP 3.2.6: an unconverted
4434
+ document-space point on an offset layer selected the wrong region of that layer entirely
4435
+ (or nothing, if the document point happened to fall outside the layer's own pixel extent)."""
4436
+ _ok, ox, oy = drawable.get_offsets()
4437
+ lx, ly = x - ox, y - oy
4438
+ lw, lh = drawable.get_width(), drawable.get_height()
4439
+ if not (0 <= lx < lw and 0 <= ly < lh):
4440
+ raise ValueError(
4441
+ 'sample point (%g, %g) is outside layer %r (%dx%d at offset %d,%d)'
4442
+ % (x, y, drawable.get_name(), lw, lh, ox, oy)
4443
+ )
4444
+ return lx, ly
4445
+
4446
+
4447
+ # A DoS floor on mode=polygon's point count -- the same reasoning as the resize/document pixel
4448
+ # caps elsewhere in this file.
4449
+ MAX_POLYGON_POINTS = 10_000
4450
+
4451
+
4452
+ def _polygon_segments(points):
4453
+ """Validate `points` (mode=polygon's own argument) and flatten it into GIMP's flat
4454
+ [x1, y1, x2, y2, ...] segment list. Every point must be an [x, y] pair of finite numbers -- a
4455
+ malformed one (not a pair, a non-numeric value, NaN/Infinity) is refused here rather than
4456
+ reaching `Gimp.Image.select_polygon` as garbage or silently coercing to 0."""
4457
+ if not isinstance(points, list) or len(points) < 3:
4458
+ raise ValueError('points must be a list of at least 3 [x, y] pairs')
4459
+ if len(points) > MAX_POLYGON_POINTS:
4460
+ raise ValueError('points must be at most %d pairs' % MAX_POLYGON_POINTS)
4461
+ segs = []
4462
+ for i, p in enumerate(points):
4463
+ if not isinstance(p, (list, tuple)) or len(p) != 2:
4464
+ raise ValueError('points[%d] must be an [x, y] pair' % i)
4465
+ x, y = p
4466
+ if (
4467
+ not isinstance(x, (int, float)) or not isinstance(y, (int, float))
4468
+ or not (math.isfinite(x) and math.isfinite(y))
4469
+ ):
4470
+ raise ValueError('points[%d] must be finite numbers' % i)
4471
+ segs.extend([float(x), float(y)])
4472
+ return segs
4473
+
4474
+
4475
+ def _shape_selection(img, mode, args, w, h):
4476
+ """Build `mode`'s shape as the ACTIVE SELECTION, in ISOLATION (every branch uses
4477
+ `Gimp.ChannelOps.REPLACE` against whatever was selected before, which is irrelevant -- the
4478
+ caller always starts from a cleared selection). `op_select` applies `invert`/`feather_px` to
4479
+ this shape ALONE, immediately after, before it is ever combined with an existing channel --
4480
+ see `op_select`'s own docstring for why that order matters."""
4481
+ REPLACE = Gimp.ChannelOps.REPLACE
4482
+ sample_merged = lib.optional_bool(args, 'sample_merged', True)
4483
+ Gimp.context_push()
4484
+ try:
4485
+ Gimp.context_set_feather(False)
4486
+ Gimp.context_set_antialias(True)
4487
+ threshold = lib.validate_range('threshold', args.get('threshold', 15), 0, 255)
4488
+ Gimp.context_set_sample_threshold(threshold / 255.0)
4489
+ Gimp.context_set_sample_merged(sample_merged)
4490
+ Gimp.context_set_sample_criterion(Gimp.SelectCriterion.COMPOSITE)
4491
+ # Set explicitly rather than inherited from whatever the session's own ambient context
4492
+ # happens to already hold (a GUI action, or an earlier call, could have left either at a
4493
+ # non-default value): sample_transparent False matches color_range/magic_wand's own
4494
+ # "colour" framing -- a fully transparent pixel has no colour to match, so it is excluded
4495
+ # from both the sampled target and the contiguous region, the same as GIMP's own tool
4496
+ # defaults. diagonal_neighbors False keeps magic_wand's contiguous region 4-connected
4497
+ # (up/down/left/right only), also GIMP's own tool default.
4498
+ Gimp.context_set_sample_transparent(False)
4499
+ Gimp.context_set_diagonal_neighbors(False)
4500
+ if mode == 'all':
4501
+ img.select_rectangle(REPLACE, 0, 0, w, h)
4502
+ elif mode in ('rectangle', 'ellipse'):
4503
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
4504
+ ww, hh = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
4505
+ (img.select_rectangle if mode == 'rectangle' else img.select_ellipse)(REPLACE, x, y, ww, hh)
4506
+ elif mode == 'polygon':
4507
+ segs = _polygon_segments(lib.require(args, 'points'))
4508
+ img.select_polygon(REPLACE, segs)
4509
+ elif mode == 'color_range':
4510
+ drawable = _layer(img, args)
4511
+ img.select_color(REPLACE, drawable, _color_arg(img, drawable, args, sample_merged))
4512
+ elif mode == 'magic_wand':
4513
+ drawable = _layer(img, args)
4514
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
4515
+ if sample_merged:
4516
+ # select_contiguous_color takes DOCUMENT coordinates when sampling the composite
4517
+ # (verified live) -- bounds-checked against the image itself, since GIMP silently
4518
+ # selects nothing for an out-of-image point rather than refusing it.
4519
+ if not (0 <= x < w and 0 <= y < h):
4520
+ raise ValueError('sample point (%g, %g) is outside the %dx%d image' % (x, y, w, h))
4521
+ img.select_contiguous_color(REPLACE, drawable, x, y)
4522
+ else:
4523
+ # ...but LOCAL coordinates when sampling one drawable (verified live) -- see
4524
+ # _layer_local_point's own comment.
4525
+ lx, ly = _layer_local_point(drawable, x, y)
4526
+ img.select_contiguous_color(REPLACE, drawable, lx, ly)
4527
+ elif mode == 'alpha':
4528
+ img.select_item(REPLACE, _layer(img, args))
4529
+ elif mode == 'channel':
4530
+ src = _require_channel(img, lib.require(args, 'source'))
4531
+ img.select_item(REPLACE, src)
4532
+ else:
4533
+ raise ValueError('unknown mode %r' % mode)
4534
+ finally:
4535
+ Gimp.context_pop()
4536
+
4537
+
4538
+ def op_select(args):
4539
+ """Every mode builds its shape ALONE first (`_shape_selection`, or a gradient painted directly
4540
+ into a scratch channel) -- `invert`/`feather_px` apply to that shape by itself, matching how a
4541
+ feathered marquee combines in a raster editor: draw the new region, feather IT, then union/
4542
+ subtract/intersect with whatever was already selected. Combining the raw (un-invert/feathered)
4543
+ shape first would let those two options reach into the EXISTING channel's content instead,
4544
+ which is not what either name promises."""
4545
+ img = _image(args)
4546
+ mode = lib.require(args, 'mode')
4547
+ name = args.get('name', 'Selection')
4548
+ combine = args.get('combine', 'replace')
4549
+ if combine not in _CHANNEL_OPS:
4550
+ raise ValueError('combine must be one of %s' % sorted(_CHANNEL_OPS))
4551
+ _refuse_mask_in_use(img, name)
4552
+ existing = _find_channel(img, name)
4553
+ if combine != 'replace' and existing is None:
4554
+ raise ValueError('combine=%s needs an existing channel named %r' % (combine, name))
4555
+ invert = bool(args.get('invert', False))
4556
+ feather_px = lib.validate_feather_px(args.get('feather_px', 0))
4557
+ if feather_px > 0 and mode in ('gradient_linear', 'gradient_radial'):
4558
+ raise ValueError(
4559
+ 'feather_px has no effect on a gradient (already a continuous ramp) -- omit it for '
4560
+ 'mode=%s rather than pass a value that would silently do nothing' % mode
4561
+ )
4562
+ w, h = img.get_width(), img.get_height()
4563
+
4564
+ try:
4565
+ Gimp.Selection.none(img)
4566
+ if mode in ('gradient_linear', 'gradient_radial'):
4567
+ def _paint(scratch):
4568
+ _paint_gradient_mask(scratch, mode, invert, args, w, h)
4569
+ img.select_item(Gimp.ChannelOps.REPLACE, scratch)
4570
+ _with_scratch_channel(img, w, h, _paint)
4571
+ # invert is already applied above, as a color swap done while painting.
4572
+ else:
4573
+ _shape_selection(img, mode, args, w, h)
4574
+ if invert:
4575
+ Gimp.Selection.invert(img)
4576
+ if feather_px > 0:
4577
+ Gimp.Selection.feather(img, feather_px)
4578
+
4579
+ # The active selection is now the new shape ALONE, already invert/feathered -- never yet
4580
+ # combined with whatever `name` previously held.
4581
+ if combine == 'replace':
4582
+ return _selection_to_channel(img, name)
4583
+
4584
+ def _combine(scratch):
4585
+ _capture_selection(img, w, h, scratch)
4586
+ img.select_item(Gimp.ChannelOps.REPLACE, existing)
4587
+ img.select_item(_CHANNEL_OPS[combine], scratch)
4588
+ _with_scratch_channel(img, w, h, _combine)
4589
+ return _selection_to_channel(img, name)
4590
+ finally:
4591
+ Gimp.Selection.none(img)
4592
+
4593
+
4594
+ def op_modify_mask(args):
4595
+ img = _image(args)
4596
+ name = lib.require(args, 'channel')
4597
+ out = args.get('output') or name
4598
+ _refuse_mask_in_use(img, out)
4599
+ ch = _require_channel(img, name)
4600
+ how = lib.require(args, 'op')
4601
+ try:
4602
+ img.select_item(Gimp.ChannelOps.REPLACE, ch)
4603
+ if how in ('expand', 'contract', 'border'):
4604
+ # Capped well below feather's own range -- these are morphological ops whose cost
4605
+ # scales with the radius AND the document's own megapixel count (see
4606
+ # `effective_morphology_px`'s own comment for the live measurements behind this).
4607
+ morphology_cap = lib.effective_morphology_px(img.get_width(), img.get_height())
4608
+ px = lib.validate_positive_px('px', lib.require(args, 'px'), max_px=morphology_cap)
4609
+ if how == 'expand':
4610
+ Gimp.Selection.grow(img, px)
4611
+ elif how == 'contract':
4612
+ _shrink_edge_locked(img, px)
4613
+ else:
4614
+ Gimp.Selection.border(img, px)
4615
+ elif how == 'feather':
4616
+ px = lib.validate_positive_px('px', lib.require(args, 'px'))
4617
+ Gimp.Selection.feather(img, px)
4618
+ elif how == 'invert':
4619
+ Gimp.Selection.invert(img)
4620
+ elif how == 'harden':
4621
+ Gimp.Selection.sharpen(img)
4622
+ elif how == 'smooth':
4623
+ # Round off jaggies: feather, then re-threshold at 50%. `px` is optional here (unlike
4624
+ # the four ops above) -- a plain smoothing pass with no caller-chosen radius is still
4625
+ # meaningful, so it floors at 1.0 rather than requiring one.
4626
+ px = lib.validate_range('px', args.get('px', 0), 0, lib.MAX_FEATHER_PX)
4627
+ Gimp.Selection.feather(img, max(px, 1.0))
4628
+ Gimp.Selection.sharpen(img)
4629
+ else:
4630
+ raise ValueError('unknown op %r' % how)
4631
+ return _selection_to_channel(img, out)
4632
+ finally:
4633
+ Gimp.Selection.none(img)
4634
+
4635
+
4636
+ _LAYER_MASK_SOURCES = {
4637
+ 'white': Gimp.AddMaskType.WHITE, 'black': Gimp.AddMaskType.BLACK,
4638
+ 'alpha': Gimp.AddMaskType.ALPHA, 'grayscale': Gimp.AddMaskType.COPY,
4639
+ }
4640
+
4641
+
4642
+ def op_layer_mask(args):
4643
+ img = _image(args)
4644
+ layer = _layer(img, args)
4645
+ how = lib.require(args, 'op')
4646
+ mask = layer.get_mask()
4647
+ if how == 'create':
4648
+ source = args.get('source', 'channel')
4649
+ # Resolve and validate EVERYTHING (the channel exists, or `source` names a real kind)
4650
+ # before touching the layer's EXISTING mask: a typo'd channel name must never destroy a
4651
+ # hand-tuned mask already in place. The new mask is built in full -- `create_mask` (a
4652
+ # candidate GimpLayerMask object, not yet attached) succeeds independently of whatever
4653
+ # mask the layer currently has -- and only once that succeeds is the old one discarded.
4654
+ if source == 'channel':
4655
+ ch = _require_channel(img, lib.require(args, 'channel'))
4656
+ img.select_item(Gimp.ChannelOps.REPLACE, ch)
4657
+ try:
4658
+ new = layer.create_mask(Gimp.AddMaskType.SELECTION)
4659
+ finally:
4660
+ Gimp.Selection.none(img)
4661
+ elif source in _LAYER_MASK_SOURCES:
4662
+ new = layer.create_mask(_LAYER_MASK_SOURCES[source])
4663
+ else:
4664
+ raise ValueError('source must be one of %s' % sorted(['channel'] + list(_LAYER_MASK_SOURCES)))
4665
+ if mask is not None:
4666
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
4667
+ if not layer.add_mask(new):
4668
+ # Best-effort: put the old mask back rather than leaving the layer maskless on top of
4669
+ # having failed to attach the new one. If this ALSO fails, the layer is left without a
4670
+ # mask either way, but the attempt costs nothing.
4671
+ if mask is not None:
4672
+ layer.add_mask(mask)
4673
+ raise lib.OpError(
4674
+ 'gimp_op_failed', 'GIMP could not attach the new mask to layer %r' % layer.get_name()
4675
+ )
4676
+ # `invert` runs AFTER add_mask -- verified live that inverting the mask object before it is
4677
+ # attached to the layer has no visible effect (the invert is silently lost).
4678
+ if lib.optional_bool(args, 'invert', False):
4679
+ new.invert(False)
4680
+ elif mask is None:
4681
+ raise ValueError('layer %r has no mask' % layer.get_name())
4682
+ elif how == 'delete':
4683
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
4684
+ elif how == 'apply':
4685
+ layer.remove_mask(Gimp.MaskApplyMode.APPLY)
4686
+ elif how == 'invert':
4687
+ mask.invert(False)
4688
+ else:
4689
+ raise ValueError('unknown op %r' % how)
4690
+ _drop_proxies(img.get_id())
4691
+ return {'layer': layer.get_name(), 'layer_id': layer.get_id(), 'op': how,
4692
+ 'has_mask': layer.get_mask() is not None}
4693
+
4694
+
4695
+ def _gray_bytes_image(data, w, h):
4696
+ """A new GRAY image carrying `data` as its one layer's pixels. Caller deletes it. `tmp` is torn
4697
+ down on any failure after its own creation (layer build, insert, or buffer write) -- otherwise
4698
+ an exception here would leave the image orphaned in GIMP with no reference left to free it."""
4699
+ tmp = Gimp.Image.new(w, h, Gimp.ImageBaseType.GRAY)
4700
+ try:
4701
+ lay = Gimp.Layer.new(tmp, 'm', w, h, Gimp.ImageType.GRAY_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
4702
+ tmp.insert_layer(lay, None, 0)
4703
+ buf = lay.get_buffer()
4704
+ buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", data)
4705
+ buf.flush()
4706
+ except Exception:
4707
+ tmp.delete()
4708
+ raise
4709
+ return tmp, lay
4710
+
4711
+
4712
+ def op_mask_preview(args):
4713
+ img = _image(args)
4714
+ ch = _require_channel(img, lib.require(args, 'channel'))
4715
+ out_path = lib.require(args, 'out_path')
4716
+ max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
4717
+ w, h = img.get_width(), img.get_height()
4718
+ data = ch.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
4719
+ tmp, lay = _gray_bytes_image(data, w, h)
4720
+ try:
4721
+ _scale_to_max(tmp, max_px)
4722
+ dw, dh = tmp.get_width(), tmp.get_height()
4723
+ if args.get('style', 'overlay') == 'mask':
4724
+ _export_stripped(tmp, out_path)
4725
+ return {'path': out_path, 'width': dw, 'height': dh, 'unmirrored_filters': []}
4726
+ small = lay.get_buffer().get(Gegl.Rectangle.new(0, 0, dw, dh), 1.0, "Y' u8",
4727
+ Gegl.AbyssPolicy.NONE)
4728
+ finally:
4729
+ tmp.delete()
4730
+ dup, unmirrored = _proxy_render(img, max_px)
4731
+ try:
4732
+ if dup.get_base_type() != Gimp.ImageBaseType.RGB:
4733
+ dup.convert_rgb()
4734
+ if (dup.get_width(), dup.get_height()) != (dw, dh):
4735
+ dup.scale(dw, dh)
4736
+ red = Gimp.Layer.new(dup, 'overlay', dw, dh, Gimp.ImageType.RGBA_IMAGE, 50.0,
4737
+ Gimp.LayerMode.NORMAL)
4738
+ dup.insert_layer(red, None, 0)
4739
+ Gimp.context_push()
4740
+ try:
4741
+ Gimp.context_set_foreground(Gegl.Color.new('red'))
4742
+ red.fill(Gimp.FillType.FOREGROUND)
4743
+ finally:
4744
+ Gimp.context_pop()
4745
+ m = red.create_mask(Gimp.AddMaskType.WHITE)
4746
+ red.add_mask(m)
4747
+ mb = m.get_buffer()
4748
+ # Quick Mask style: red covers what is NOT selected.
4749
+ mb.set(Gegl.Rectangle.new(0, 0, dw, dh), "Y' u8", small.translate(bytes(range(255, -1, -1))))
4750
+ mb.flush()
4751
+ dup.flatten()
4752
+ _export_stripped(dup, out_path)
4753
+ return {'path': out_path, 'width': dw, 'height': dh, 'unmirrored_filters': unmirrored}
4754
+ finally:
4755
+ dup.delete()
4756
+
4757
+
4758
+ def op_load_mask(args):
4759
+ """A grey mask image (PNG/JPEG, white = selected) -> a named channel. With a layer, the mask
4760
+ is taken to cover that layer's own bounds (e.g. a mask computed from `render_layer`).
4761
+
4762
+ Flattened onto an explicit OPAQUE BLACK layer at the bottom of the stack always -- never
4763
+ GIMP's own flatten default (the session's current background colour, verified live) -- so a
4764
+ source smaller than its own canvas, or carrying alpha, never picks up stray colour for the
4765
+ gap/transparent area: a transparent (or uncovered) pixel means "not part of the mask" NOT
4766
+ "selected". Every alpha-carrying layer's own composite space is set to RGB_PERCEPTUAL before
4767
+ flattening, so the result is value x alpha IN PERCEPTUAL SPACE (verified live: a 50%-alpha
4768
+ white pixel over black reads ~128, the plain 255 x 0.5), not a linear-light blend (which would
4769
+ read ~186 for the same input) -- a mask is a plain 0-255 coverage value, not a photographed
4770
+ light, so the perceptual (byte-arithmetic) reading is the one that matches every other mask
4771
+ value this bridge produces."""
4772
+ img = _image(args)
4773
+ name = args.get('name', 'Mask')
4774
+ _refuse_mask_in_use(img, name)
4775
+ path = lib.require(args, 'path')
4776
+ basename = os.path.basename(path)
4777
+ if not os.path.exists(path):
4778
+ raise FileNotFoundError('no file named %s to load as a mask' % basename)
4779
+ w, h = img.get_width(), img.get_height()
4780
+ if args.get('layer') or args.get('layer_id') is not None:
4781
+ layer = _layer(img, args)
4782
+ _ok, ox, oy = layer.get_offsets()
4783
+ tw, th = layer.get_width(), layer.get_height()
4784
+ else:
4785
+ ox = oy = 0
4786
+ tw, th = w, h
4787
+ load_error = None
4788
+ try:
4789
+ src = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(path))
4790
+ except Exception as e:
4791
+ src = None
4792
+ load_error = str(e)
4793
+ if src is None:
4794
+ detail = _path_free_detail(load_error)
4795
+ raise lib.OpError('gimp_op_failed', 'could not load %s as a mask%s' % (basename, detail))
4796
+ try:
4797
+ lib.validate_loaded_mask_dims(src.get_width(), src.get_height())
4798
+ if src.get_base_type() != Gimp.ImageBaseType.GRAY:
4799
+ src.convert_grayscale()
4800
+ for src_layer in src.get_layers():
4801
+ if src_layer.has_alpha():
4802
+ src_layer.set_composite_space(Gimp.LayerColorSpace.RGB_PERCEPTUAL)
4803
+ bg = Gimp.Layer.new(src, 'mask-bg', src.get_width(), src.get_height(),
4804
+ Gimp.ImageType.GRAY_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
4805
+ src.insert_layer(bg, None, len(src.get_layers()))
4806
+ Gimp.context_push()
4807
+ try:
4808
+ Gimp.context_set_foreground(Gegl.Color.new('black'))
4809
+ bg.fill(Gimp.FillType.FOREGROUND)
4810
+ finally:
4811
+ Gimp.context_pop()
4812
+ if (src.get_width(), src.get_height()) != (tw, th):
4813
+ src.scale(tw, th)
4814
+ flat = src.flatten()
4815
+ data = flat.get_buffer().get(Gegl.Rectangle.new(0, 0, tw, th), 1.0, "Y' u8",
4816
+ Gegl.AbyssPolicy.NONE)
4817
+ finally:
4818
+ src.delete()
4819
+ ch = _replace_named_channel(img, name, w, h)
4820
+ x0, y0, x1, y1 = lib.compute_mask_paste_rect(ox, oy, tw, th, w, h)
4821
+ if x1 > x0 and y1 > y0:
4822
+ if (ox, oy, tw, th) == (0, 0, w, h):
4823
+ sub = data
4824
+ else:
4825
+ sub = b''.join(data[(y - oy) * tw + (x0 - ox):(y - oy) * tw + (x1 - ox)]
4826
+ for y in range(y0, y1))
4827
+ buf = ch.get_buffer()
4828
+ buf.set(Gegl.Rectangle.new(x0, y0, x1 - x0, y1 - y0), "Y' u8", sub)
4829
+ buf.flush()
4830
+ ch.update(0, 0, w, h)
4831
+ Gimp.Selection.none(img)
4832
+ _drop_proxies(img.get_id())
4833
+ selected, fraction = _channel_coverage(ch, w, h)
4834
+ return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
4835
+
4836
+
4837
+ def op_render_layer(args):
4838
+ """One layer's own pixels (not the composite), downscaled, to out_path -- the input for a mask
4839
+ that should follow that layer. Returns the layer's bounds in document pixels.
4840
+
4841
+ Rendered as if the layer were fully VISIBLE, at 100% opacity, in Normal mode -- set on the
4842
+ duplicated layer before flatten, so a hidden, partially-transparent, or blend-mode layer still
4843
+ renders its own pixels plainly rather than the (possibly empty or blended-dark) result its
4844
+ current display settings would otherwise produce. The layer's own mask and any live filters
4845
+ are still included, exactly as they would render in the document. Any remaining transparency
4846
+ (the layer's own alpha, not just visibility/opacity/mode) is flattened onto an explicit OPAQUE
4847
+ WHITE background -- not whatever the session's own ambient context background happens to be --
4848
+ since the output feeds a subject/object detector that expects an ordinary opaque photo."""
4849
+ img = _image(args)
4850
+ layer = _layer(img, args)
4851
+ out_path = lib.require(args, 'out_path')
4852
+ max_px = lib.validate_max_px(int(args.get('max_px', 2048)))
4853
+ lw, lh = layer.get_width(), layer.get_height()
4854
+ _ok, ox, oy = layer.get_offsets()
4855
+ tmp = Gimp.Image.new(lw, lh, Gimp.ImageBaseType.RGB)
4856
+ try:
4857
+ nl = Gimp.Layer.new_from_drawable(layer, tmp)
4858
+ tmp.insert_layer(nl, None, 0)
4859
+ nl.set_offsets(0, 0)
4860
+ nl.set_visible(True)
4861
+ nl.set_opacity(100.0)
4862
+ nl.set_mode(Gimp.LayerMode.NORMAL)
4863
+ bg = Gimp.Layer.new(tmp, 'render-bg', lw, lh, Gimp.ImageType.RGB_IMAGE, 100.0,
4864
+ Gimp.LayerMode.NORMAL)
4865
+ tmp.insert_layer(bg, None, 1)
4866
+ Gimp.context_push()
4867
+ try:
4868
+ Gimp.context_set_foreground(Gegl.Color.new('white'))
4869
+ bg.fill(Gimp.FillType.FOREGROUND)
4870
+ finally:
4871
+ Gimp.context_pop()
4872
+ _scale_to_max(tmp, max_px)
4873
+ tmp.flatten()
4874
+ _export_stripped(tmp, out_path)
4875
+ return {'path': out_path, 'width': tmp.get_width(), 'height': tmp.get_height(),
4876
+ 'layer_id': layer.get_id(), 'bounds': {'x': ox, 'y': oy, 'width': lw, 'height': lh}}
4877
+ finally:
4878
+ tmp.delete()
4879
+
4880
+
3053
4881
  def op_close(args):
3054
4882
  img = _image(args)
3055
4883
  _drop_proxies(img.get_id())
@@ -3067,6 +4895,11 @@ OPS = {
3067
4895
  'select_none': op_select_none, 'layer': op_layer, 'bake': op_bake,
3068
4896
  'create_document': op_create_document, 'place_image': op_place_image,
3069
4897
  'canvas': op_canvas, 'convert_image_mode': op_convert_image_mode,
4898
+ 'text': op_text, 'fonts': op_fonts,
4899
+ 'select': op_select, 'modify_mask': op_modify_mask, 'layer_mask': op_layer_mask,
4900
+ 'mask_preview': op_mask_preview, 'load_mask': op_load_mask, 'render_layer': op_render_layer,
4901
+ 'transform_layer': op_transform_layer,
4902
+ 'match_layer': op_match_layer,
3070
4903
  }
3071
4904
 
3072
4905