editmamei 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/gimp/bridge/lib.py +1790 -26
  3. package/dist/backends/gimp/bridge/ops.py +3013 -168
  4. package/dist/bin/editmamei-core-darwin-arm64 +0 -0
  5. package/dist/bin/editmamei-core-darwin-x64 +0 -0
  6. package/dist/bin/editmamei-core-win-x64.exe +0 -0
  7. package/dist/cli/config.js +7 -0
  8. package/dist/core/server.js +58 -13
  9. package/dist/core/settings.js +7 -0
  10. package/dist/core/tool-activity.js +15 -1
  11. package/dist/core/tool-groups.js +9 -1
  12. package/dist/core/tool-tiers.js +9 -1
  13. package/dist/delivery/client.js +3 -0
  14. package/dist/modules/gimp/index.js +12 -2
  15. package/dist/skills/editmamei-skill.zip +0 -0
  16. package/dist/telemetry/sanitize.js +15 -1
  17. package/dist/tools/adjustment-tools.js +9 -1
  18. package/dist/tools/document-tools.js +118 -21
  19. package/dist/tools/filter-tools.js +2 -1
  20. package/dist/tools/gimp-adjustment-tools.js +7 -6
  21. package/dist/tools/gimp-checkpoint-tools.js +7 -1
  22. package/dist/tools/gimp-core-tools.js +10 -11
  23. package/dist/tools/gimp-document-tools.js +100 -18
  24. package/dist/tools/gimp-effect-tools.js +56 -10
  25. package/dist/tools/gimp-fill-tools.js +256 -0
  26. package/dist/tools/gimp-filter-tools.js +4 -1
  27. package/dist/tools/gimp-geometry-tools.js +2 -2
  28. package/dist/tools/gimp-inspect-tools.js +48 -10
  29. package/dist/tools/gimp-layer-tools.js +7 -24
  30. package/dist/tools/gimp-match-tools.js +133 -0
  31. package/dist/tools/gimp-result-text.js +104 -0
  32. package/dist/tools/gimp-retouch-tools.js +238 -0
  33. package/dist/tools/gimp-selection-tools.js +374 -0
  34. package/dist/tools/gimp-shared.js +12 -0
  35. package/dist/tools/gimp-text-tools.js +241 -0
  36. package/dist/tools/gimp-transform-layer-tools.js +215 -0
  37. package/dist/tools/gimp-verify-tools.js +13 -15
  38. package/dist/tools/image-placement-tools.js +6 -1
  39. package/dist/tools/sky-tools.js +2 -1
  40. package/dist/utils/gimp-path.js +18 -22
  41. package/dist/utils/local-path.js +99 -0
  42. package/dist/utils/operation-timeouts.js +12 -2
  43. package/dist/version.js +1 -1
  44. package/package.json +5 -5
  45. package/dist/tools/gimp-mask-tools.js +0 -102
@@ -19,7 +19,9 @@
19
19
 
20
20
  import math
21
21
  import os
22
+ import shutil
22
23
  import sys
24
+ import tempfile
23
25
  import time
24
26
  import traceback
25
27
 
@@ -47,7 +49,10 @@ RAW_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.raf', '.orf', '.rw2'
47
49
 
48
50
  def _image(args):
49
51
  image_id = lib.require(args, 'image')
50
- img = Gimp.Image.get_by_id(int(image_id))
52
+ gid = lib.gimp_id(image_id)
53
+ if gid is None:
54
+ raise ValueError('no open image with id %s' % image_id)
55
+ img = Gimp.Image.get_by_id(gid)
51
56
  if img is None or not img.is_valid():
52
57
  raise ValueError('no open image with id %s' % image_id)
53
58
  # A preview proxy (see PROXIES below) is an internal, filter-free duplicate this bridge made
@@ -77,7 +82,8 @@ def _layer(img, args):
77
82
  (possibly closed) image and let an op silently touch the wrong document."""
78
83
  layer_id = args.get('layer_id')
79
84
  if layer_id is not None:
80
- layer = Gimp.Layer.get_by_id(int(layer_id))
85
+ gid = lib.gimp_id(layer_id)
86
+ layer = Gimp.Layer.get_by_id(gid) if gid is not None else None
81
87
  owner = _owning_image(layer)
82
88
  if layer is None or owner is None or owner.get_id() != img.get_id():
83
89
  raise ValueError('no layer with id %s on image %s' % (layer_id, img.get_id()))
@@ -423,6 +429,16 @@ _TRANSFER_MODE_ENUM = {
423
429
  'highlights': Gimp.TransferMode.HIGHLIGHTS,
424
430
  }
425
431
 
432
+ # gimp_transform_layer's `interpolation` choices, keyed the same way lib.TRANSFORM_LAYER_
433
+ # INTERPOLATIONS validates them against.
434
+ _INTERPOLATION_ENUM = {
435
+ 'none': Gimp.InterpolationType.NONE,
436
+ 'linear': Gimp.InterpolationType.LINEAR,
437
+ 'cubic': Gimp.InterpolationType.CUBIC,
438
+ 'nohalo': Gimp.InterpolationType.NOHALO,
439
+ 'lohalo': Gimp.InterpolationType.LOHALO,
440
+ }
441
+
426
442
 
427
443
  def _set_exposure(cfg, params):
428
444
  cfg.set_property('exposure', params['exposure'])
@@ -550,10 +566,22 @@ def _set_drop_shadow(cfg, params):
550
566
  # offset/radius does not grow the layer or canvas (measured at the schema maxima and in
551
567
  # combination: no size change), it just gets cut off at the edge like any other
552
568
  # DrawableFilter here, so no separate cap against "ballooning" is needed.
553
- cfg.set_property('x', params['x'])
554
- cfg.set_property('y', params['y'])
555
- cfg.set_property('radius', params['radius'])
556
- cfg.set_property('opacity', params['opacity'])
569
+ # outline / outer_glow reuse this operation and record only what they use: no offset
570
+ # (x, y = 0), no blur for outline (radius = 0), full opacity for outline. They add a grown
571
+ # core and a color; a drop_shadow record has neither, so it renders as before (no growth,
572
+ # black).
573
+ cfg.set_property('x', params.get('x', 0.0))
574
+ cfg.set_property('y', params.get('y', 0.0))
575
+ cfg.set_property('radius', params.get('radius', 0.0))
576
+ cfg.set_property('opacity', params.get('opacity', 1.0))
577
+ cfg.set_property('grow-shape', 'circle')
578
+ cfg.set_property('grow-radius', params.get('grow-radius', 0.0))
579
+ cfg.set_property('color', Gegl.Color.new(params.get('color', '#000000')))
580
+
581
+
582
+ def _set_surface_blur(cfg, params):
583
+ cfg.set_property('blur-radius', params['blur-radius'])
584
+ cfg.set_property('edge-preservation', params['edge-preservation'] * lib.BILATERAL_EDGE_SCALE)
557
585
 
558
586
 
559
587
  SETTERS = {
@@ -576,16 +604,10 @@ SETTERS = {
576
604
  'gegl:focus-blur': _set_lens_blur,
577
605
  'gegl:noise-rgb': _set_add_noise,
578
606
  'gegl:dropshadow': _set_drop_shadow,
607
+ 'gegl:bilateral-filter': _set_surface_blur,
579
608
  }
580
609
 
581
610
 
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
611
  def _append_masked(img, layer, f, mask):
590
612
  """Append f, confined to channel `mask` if given. A filter appended while a selection is
591
613
  active keeps that selection as its own mask, which persists after the selection is
@@ -610,7 +632,7 @@ def _append_masked(img, layer, f, mask):
610
632
  runs before raising either way, so a refused filter that never actually attached to anything
611
633
  doesn't linger as an orphaned DrawableFilter object."""
612
634
  if mask:
613
- img.select_item(Gimp.ChannelOps.REPLACE, _channel_by_name(img, mask))
635
+ img.select_item(Gimp.ChannelOps.REPLACE, _require_channel(img, mask))
614
636
  else:
615
637
  Gimp.Selection.none(img)
616
638
  try:
@@ -635,12 +657,14 @@ def _append_masked(img, layer, f, mask):
635
657
  Gimp.Selection.none(img)
636
658
 
637
659
 
638
- def _apply_filter(img, args, operation, params, default_name, type_=None):
660
+ def _apply_filter(img, args, operation, params, default_name, type_=None, tag=None):
639
661
  """Create (or, with filter_id, update in place) a filter and record it in the ledger.
640
662
  A new filter may be confined to a mask channel (`mask`); a re-edit keeps the mask the
641
663
  filter was created with. `type_` is the user-facing adjust `type` (e.g. "shadows_highlights")
642
664
  recorded alongside `operation` (the GEGL op name) so a filter listing can report what the
643
- model asked for, not just the GEGL internals."""
665
+ model asked for, not just the GEGL internals. `tag`, when given, marks the record as made by
666
+ another op (`lib.MATCH_TAG`) so that op can find its own filters later; a re-edit keeps the
667
+ tag the record already had."""
644
668
  filters, unknown = _ledger_get(img)
645
669
  if args.get('filter_id') is not None:
646
670
  f = _existing_filter(img, args, operation)
@@ -655,17 +679,28 @@ def _apply_filter(img, args, operation, params, default_name, type_=None):
655
679
  SETTERS[operation](f.get_config(), params)
656
680
  params['mask'] = args.get('mask')
657
681
  _append_masked(img, layer, f, params['mask'])
658
- filters[f.get_name()] = {'operation': operation, 'type': type_, 'params': params}
682
+ record = {'operation': operation, 'type': type_, 'params': params}
683
+ tag = tag or filters.get(f.get_name(), {}).get('tag')
684
+ if tag:
685
+ record['tag'] = tag
686
+ filters[f.get_name()] = record
659
687
  _ledger_put(img, filters, unknown)
660
688
  return {'filter_id': f.get_id(), 'name': f.get_name(), 'type': type_, 'mask': params['mask']}
661
689
 
662
690
 
663
691
  # ---- operations -------------------------------------------------------------------------
664
692
 
693
+ def _document_ids():
694
+ """Ids of the open documents. The preview proxies in `PROXIES` are real GIMP images but belong
695
+ to the bridge, so they are never listed."""
696
+ hidden = [p.get_id() for p in PROXIES.values() if p.is_valid()]
697
+ return lib.visible_image_ids([i.get_id() for i in Gimp.get_images()], hidden)
698
+
699
+
665
700
  def op_ping(args):
666
701
  major, minor, micro = lib.parse_gimp_version(Gimp.version())
667
702
  return {'major': major, 'minor': minor, 'micro': micro,
668
- 'images': [i.get_id() for i in Gimp.get_images()]}
703
+ 'images': _document_ids()}
669
704
 
670
705
 
671
706
  # `precision` open-time promotion, using the enum names GIMP 3 actually exposes (verified live).
@@ -678,6 +713,67 @@ _PRECISION_ENUM = {
678
713
  }
679
714
 
680
715
 
716
+ # Rotation/flip steps (`lib.EXIF_ORIENTATION_STEPS`) as GIMP calls. The image variants act on a
717
+ # whole image; the layer variants on one (possibly unattached) layer.
718
+ _ORIENTATION_IMAGE_STEPS = {
719
+ 'cw90': lambda img: img.rotate(Gimp.RotationType.DEGREES90),
720
+ 'cw180': lambda img: img.rotate(Gimp.RotationType.DEGREES180),
721
+ 'cw270': lambda img: img.rotate(Gimp.RotationType.DEGREES270),
722
+ 'flip_h': lambda img: img.flip(Gimp.OrientationType.HORIZONTAL),
723
+ 'flip_v': lambda img: img.flip(Gimp.OrientationType.VERTICAL),
724
+ }
725
+ _ORIENTATION_LAYER_STEPS = {
726
+ 'cw90': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES90, True, 0, 0),
727
+ 'cw180': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES180, True, 0, 0),
728
+ 'cw270': lambda layer: layer.transform_rotate_simple(Gimp.RotationType.DEGREES270, True, 0, 0),
729
+ 'flip_h': lambda layer: layer.transform_flip_simple(Gimp.OrientationType.HORIZONTAL, True, 0),
730
+ 'flip_v': lambda layer: layer.transform_flip_simple(Gimp.OrientationType.VERTICAL, True, 0),
731
+ }
732
+
733
+ # Formats whose loader leaves EXIF orientation unapplied in a non-interactive load. Only JPEG is
734
+ # rotated here: other loaders apply their own orientation (HEIF's container rotation, TIFF's
735
+ # orientation tag, raw-develop plug-ins), and `.xcf` stores upright pixels, so rotating them
736
+ # again would turn them twice.
737
+ _ORIENTATION_EXTENSIONS = {'.jpg', '.jpeg', '.jpe', '.jfif'}
738
+
739
+
740
+ def _file_orientation(path, ext):
741
+ """(orientation, metadata) from the source file at `path`: the EXIF Orientation (2-8) and the
742
+ file's own metadata object, or (None, None) when the tag is missing, normal (1), unreadable,
743
+ or the format is not one this bridge orients. Read from the file itself: the metadata a load
744
+ attaches to the image is not a reliable source (it can carry another file's tags)."""
745
+ if ext not in _ORIENTATION_EXTENSIONS:
746
+ return None, None
747
+ try:
748
+ md = Gimp.Metadata.load_from_file(Gio.File.new_for_path(path))
749
+ except Exception:
750
+ return None, None
751
+ orientation = lib.read_exif_orientation(md)
752
+ if orientation is None or orientation == 1:
753
+ return None, None
754
+ return orientation, md
755
+
756
+
757
+ def _upright_open_image(img, orientation, md):
758
+ """Turns a freshly loaded image upright for the EXIF `orientation` (2-8) and gives it the
759
+ source file's own metadata with the orientation tag dropped (absent means normal; a tag SET
760
+ to 1 does not reliably stick), so a viewer of a later copy cannot rotate it again."""
761
+ for step in lib.exif_orientation_steps(orientation):
762
+ _ORIENTATION_IMAGE_STEPS[step](img)
763
+ # The pixels are already upright; metadata is a courtesy to later copies and never fails an open.
764
+ try:
765
+ if lib.clear_exif_orientation(md):
766
+ img.set_metadata(md)
767
+ except Exception:
768
+ pass
769
+
770
+
771
+ def _upright_placed_layer(layer, orientation):
772
+ """Turns a freshly loaded, unattached layer upright for the EXIF `orientation` (2-8)."""
773
+ for step in lib.exif_orientation_steps(orientation):
774
+ _ORIENTATION_LAYER_STEPS[step](layer)
775
+
776
+
681
777
  def op_open(args):
682
778
  path = lib.require(args, 'path')
683
779
  if not os.path.exists(path):
@@ -712,6 +808,9 @@ def op_open(args):
712
808
  # op here works on the whole canvas, and a transform with a selection active moves only
713
809
  # the selected pixels (leaving a floating selection), so it is cleared on the way in.
714
810
  Gimp.Selection.none(img)
811
+ orientation_applied, source_md = _file_orientation(path, ext)
812
+ if orientation_applied is not None:
813
+ _upright_open_image(img, orientation_applied, source_md)
715
814
  if precision != 'keep':
716
815
  # `convert_precision` does NOT raise on failure -- verified live that it returns
717
816
  # plain `False` (GIMP logs a "Calling error" to stderr, e.g. "must not be of type
@@ -735,7 +834,10 @@ def op_open(args):
735
834
  except Exception:
736
835
  img.delete()
737
836
  raise
738
- return _describe(img)
837
+ result = _describe(img)
838
+ if orientation_applied is not None:
839
+ result['orientation_applied'] = orientation_applied
840
+ return result
739
841
 
740
842
 
741
843
  def _existing_ledger_params(img, args, operation):
@@ -851,7 +953,7 @@ def _filter_record(filters, layer, f):
851
953
  rec = filters.get(f.get_name())
852
954
  if rec and rec['operation'] == f.get_operation_name():
853
955
  source = 'editmamei'
854
- type_ = rec.get('type') or lib.OPERATION_TYPES.get(rec['operation'])
956
+ type_ = lib.record_type(rec)
855
957
  mask = rec['params'].get('mask')
856
958
  params = lib.user_params(type_, rec['params'])
857
959
  else:
@@ -906,12 +1008,655 @@ def op_effect(args):
906
1008
  lib.reject_foreign_fields(effect_type, args)
907
1009
  img = _image(args)
908
1010
  operation = lib.EFFECT_OPERATIONS[effect_type]
909
- defaults = _existing_ledger_params(img, args, operation) or lib.EFFECT_CREATE_DEFAULTS[effect_type]
1011
+ if args.get('filter_id') is not None:
1012
+ # drop_shadow, outline and outer_glow are all gegl:dropshadow, which `_existing_filter`'s
1013
+ # operation check cannot tell apart; the ledger's `type` can.
1014
+ rec = _ledger_get(img)[0].get(_existing_filter(img, args, operation).get_name())
1015
+ existing_type = lib.record_type(rec)
1016
+ if existing_type and existing_type != effect_type:
1017
+ raise ValueError('filter %s is %s, not %s' % (args['filter_id'], existing_type, effect_type))
1018
+ # A re-edit merges over the creation defaults, so a field a legacy record never stored (and
1019
+ # the builder reads) falls back to its default instead of raising KeyError.
1020
+ defaults = dict(lib.EFFECT_CREATE_DEFAULTS[effect_type])
1021
+ defaults.update(_existing_ledger_params(img, args, operation) or {})
910
1022
  params = builder(args, defaults)
911
1023
  default_name = effect_type.replace('_', ' ').title()
912
1024
  return _apply_filter(img, args, operation, params, default_name, type_=effect_type)
913
1025
 
914
1026
 
1027
+ # ---- match_layer ------------------------------------------------------------------------
1028
+
1029
+ def _match_hist(drawable, channel):
1030
+ """(mean, std_dev, pixel count) of `channel` over `drawable` under the image's current
1031
+ selection, in 0-255. A selection-aware read (verified live): with no selection it covers the
1032
+ whole drawable, so callers refuse an EMPTY selection before asking. GIMP reports the mean and
1033
+ spread in 0-255 for 8-bit images and in 0-1 for every higher precision (verified live)."""
1034
+ ok, mean, std_dev, _median, _pixels, count, _pct = drawable.histogram(CHANNELS[channel], 0.0, 1.0)
1035
+ if not ok:
1036
+ raise lib.OpError('gimp_op_failed', 'GIMP could not read the %s histogram' % channel)
1037
+ scale = lib.histogram_scale(drawable.get_image().get_precision().value_nick)
1038
+ return mean * scale, std_dev * scale, int(round(count))
1039
+
1040
+
1041
+ def _match_select_visible(dup, target):
1042
+ """Select what `target` shows: its alpha (hardened to 50%) intersected with its layer mask."""
1043
+ dup.select_item(Gimp.ChannelOps.REPLACE, target)
1044
+ mask = target.get_mask()
1045
+ if mask is not None:
1046
+ dup.select_item(Gimp.ChannelOps.INTERSECT, mask)
1047
+ _harden_selection(dup)
1048
+
1049
+
1050
+ def _match_render(img, layer, roi, previous_filter_indexes, mode):
1051
+ """A throwaway duplicate of `img`, cropped to `roi` (x, y, w, h), scaled down by
1052
+ `lib.match_measure_scale`, with the layer's previous match filters removed. `mode` 'source'
1053
+ keeps only the layer (and its groups) visible; 'below' hides it and everything above it.
1054
+ Returns (dup, target, factor); the caller selects, flattens, and deletes `dup`."""
1055
+ all_ids = [l.get_id() for l in _all_layers(img)]
1056
+ dup = img.duplicate()
1057
+ try:
1058
+ dup_layers = _all_layers(dup)
1059
+ if len(dup_layers) != len(all_ids):
1060
+ raise lib.OpError('gimp_op_failed', 'could not duplicate the layer stack for measuring')
1061
+ target = dup_layers[all_ids.index(layer.get_id())]
1062
+ ancestors = {a.get_id() for a in _layer_ancestors(target)}
1063
+ x, y, w, h = roi
1064
+ if (w, h) != (img.get_width(), img.get_height()):
1065
+ dup.crop(w, h, x, y)
1066
+ factor = lib.match_measure_scale(w, h)
1067
+ if factor < 1.0:
1068
+ dup.scale(max(1, int(round(w * factor))), max(1, int(round(h * factor))))
1069
+ target_filters = target.get_filters()
1070
+ for i in previous_filter_indexes:
1071
+ target_filters[i].delete()
1072
+ if mode == 'source':
1073
+ for l in dup_layers:
1074
+ l.set_visible(l.get_id() == target.get_id() or l.get_id() in ancestors)
1075
+ target.set_opacity(100.0)
1076
+ target.set_mode(Gimp.LayerMode.NORMAL)
1077
+ else:
1078
+ index = dup_layers.index(target)
1079
+ above_or_self = {l.get_id() for l in dup_layers[:index + 1]} - ancestors
1080
+ for l in dup_layers:
1081
+ if l.get_id() in above_or_self:
1082
+ l.set_visible(False)
1083
+ if not any(l.get_visible() and not l.is_group() for l in dup_layers):
1084
+ raise ValueError('there are no visible layers below this layer to match against')
1085
+ return dup, target, factor
1086
+ except Exception:
1087
+ dup.delete()
1088
+ raise
1089
+
1090
+
1091
+ def _match_stats(flat, channels, factor):
1092
+ """Per-channel (mean, std_dev, full-resolution pixel count) on a measuring copy."""
1093
+ out = {}
1094
+ for c in channels:
1095
+ mean, std, count = _match_hist(flat, c)
1096
+ out[c] = (mean, std, lib.match_full_res_count(count, factor))
1097
+ return out
1098
+
1099
+
1100
+ def _match_measure_source(img, layer, roi, previous_filter_indexes, channels):
1101
+ dup, target, factor = _match_render(img, layer, roi, previous_filter_indexes, 'source')
1102
+ try:
1103
+ _match_select_visible(dup, target)
1104
+ if Gimp.Selection.is_empty(dup):
1105
+ raise ValueError('the layer has no visible pixels to measure')
1106
+ flat = dup.flatten()
1107
+ return _match_stats(flat, channels, factor)
1108
+ finally:
1109
+ dup.delete()
1110
+
1111
+
1112
+ def _match_measure_reference(img, layer, roi, previous_filter_indexes, channels, opts):
1113
+ dup, target, factor = _match_render(img, layer, roi, previous_filter_indexes, 'below')
1114
+ try:
1115
+ if opts['reference'] == 'surround':
1116
+ _match_select_visible(dup, target)
1117
+ if Gimp.Selection.is_empty(dup):
1118
+ raise ValueError('the layer has no visible pixels to measure')
1119
+ visible = Gimp.Selection.save(dup)
1120
+ Gimp.Selection.grow(dup, lib.match_scaled_px(opts['surround_px'], factor))
1121
+ dup.select_item(Gimp.ChannelOps.SUBTRACT, visible)
1122
+ _harden_selection(dup)
1123
+ if Gimp.Selection.is_empty(dup):
1124
+ raise ValueError(
1125
+ 'there is no canvas around the layer to sample (it fills the canvas); use '
1126
+ "reference 'below'"
1127
+ )
1128
+ else:
1129
+ _ok, ox, oy = target.get_offsets()
1130
+ dup.select_rectangle(Gimp.ChannelOps.REPLACE, ox, oy, target.get_width(), target.get_height())
1131
+ if Gimp.Selection.is_empty(dup):
1132
+ raise ValueError('the layer has no visible pixels to measure')
1133
+ flat = dup.flatten()
1134
+ return _match_stats(flat, channels, factor)
1135
+ finally:
1136
+ dup.delete()
1137
+
1138
+
1139
+ def _delete_filters(img, created):
1140
+ """Remove filters this call just made (by id) and their ledger records; never raises."""
1141
+ names = set()
1142
+ for c in created:
1143
+ try:
1144
+ _layer, f = _find_filter(img, c['filter_id'])
1145
+ names.add(f.get_name())
1146
+ f.delete()
1147
+ except Exception:
1148
+ pass
1149
+ if names:
1150
+ try:
1151
+ filters, unknown = _ledger_get(img)
1152
+ for name in names:
1153
+ filters.pop(name, None)
1154
+ _ledger_put(img, filters, unknown, removed=names)
1155
+ except Exception:
1156
+ pass
1157
+
1158
+
1159
+ class _PreservedSelection(object):
1160
+ """Keeps the user's selection across ops that clear or replace it: the selection is saved to
1161
+ a temporary channel on entry and restored (or cleared, if there was none) on exit, whichever
1162
+ way the body ends."""
1163
+
1164
+ def __init__(self, img):
1165
+ self.img = img
1166
+ self.saved = None
1167
+
1168
+ def __enter__(self):
1169
+ if not Gimp.Selection.is_empty(self.img):
1170
+ self.saved = Gimp.Selection.save(self.img)
1171
+ return self
1172
+
1173
+ def __exit__(self, exc_type, exc, tb):
1174
+ try:
1175
+ if self.saved is not None:
1176
+ self.img.select_item(Gimp.ChannelOps.REPLACE, self.saved)
1177
+ else:
1178
+ Gimp.Selection.none(self.img)
1179
+ finally:
1180
+ if self.saved is not None:
1181
+ self.img.remove_channel(self.saved)
1182
+ return False
1183
+
1184
+
1185
+ def _match_refine_edges(img, layer, contract_px, feather_px):
1186
+ """Contract then feather the layer's mask (one made from its alpha when it has none). Only
1187
+ the part of the mask on the canvas is rewritten; alpha is never touched. The caller holds the
1188
+ selection (`_PreservedSelection`). Returns whether a mask was created."""
1189
+ created = False
1190
+ mask = layer.get_mask()
1191
+ if mask is None:
1192
+ mask = layer.create_mask(Gimp.AddMaskType.ALPHA)
1193
+ layer.add_mask(mask)
1194
+ created = True
1195
+ try:
1196
+ img.select_item(Gimp.ChannelOps.REPLACE, mask)
1197
+ if contract_px:
1198
+ _shrink_edge_locked(img, contract_px)
1199
+ if feather_px:
1200
+ Gimp.Selection.feather(img, feather_px)
1201
+ _ok, ox, oy = layer.get_offsets()
1202
+ cw, ch = img.get_width(), img.get_height()
1203
+ x0, y0 = max(0, ox), max(0, oy)
1204
+ x1, y1 = min(cw, ox + layer.get_width()), min(ch, oy + layer.get_height())
1205
+ if x1 > x0 and y1 > y0:
1206
+ data = img.get_selection().get_buffer().get(
1207
+ Gegl.Rectangle.new(x0, y0, x1 - x0, y1 - y0), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
1208
+ )
1209
+ buf = mask.get_buffer()
1210
+ buf.set(Gegl.Rectangle.new(x0 - ox, y0 - oy, x1 - x0, y1 - y0), "Y' u8", data)
1211
+ buf.flush()
1212
+ mask.update(x0 - ox, y0 - oy, x1 - x0, y1 - y0)
1213
+ except Exception:
1214
+ if created:
1215
+ try:
1216
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
1217
+ except Exception:
1218
+ pass
1219
+ raise
1220
+ return created
1221
+
1222
+
1223
+ def op_match_layer(args):
1224
+ """Pull a pasted layer's colour and tone toward what surrounds it, as live curves filters.
1225
+
1226
+ Measure (on throwaway duplicates cropped to the layer's box grown by the surround, deleted on
1227
+ every path -- the document, its selection and its visibility are not touched while
1228
+ measuring): the layer's visible pixels (alpha AND layer mask, its own non-match filters
1229
+ rendered) against the reference -- a ring of canvas around the visible area ('surround'), or
1230
+ the whole composite of everything below under the layer's box ('below'). Per channel,
1231
+ `lib.match_transfer` gives the linear map and `lib.match_curve_points` its curve.
1232
+
1233
+ 'both' and 'color' write red/green/blue curves (matching each channel's mean and spread
1234
+ already matches tone, so 'both' adds no value curve on top); 'tone' writes one value curve
1235
+ from the VALUE (max of R,G,B) statistics. Each curve is a `gimp:curves` filter named 'Match
1236
+ <channel>', ledgered with `lib.MATCH_TAG`; a repeat call deletes the layer's previous tagged
1237
+ filters and writes fresh ones. The edge options then refine the layer mask."""
1238
+ img = _image(args)
1239
+ layer = _layer(img, args)
1240
+ if layer.is_group():
1241
+ raise ValueError(
1242
+ 'cannot match a layer group: it has no pixels of its own; match a layer inside it'
1243
+ )
1244
+ if layer.is_text_layer():
1245
+ raise ValueError('cannot match a text layer: bake it to pixels first (gimp_bake)')
1246
+ if layer.get_lock_content():
1247
+ raise ValueError('layer %r is locked; unlock it first' % layer.get_name())
1248
+ if lib.is_linear_precision(img.get_precision().value_nick):
1249
+ raise ValueError(
1250
+ 'this image stores linear light (%s); convert it to a non-linear precision first'
1251
+ % img.get_precision().value_nick
1252
+ )
1253
+ lw, lh = layer.get_width(), layer.get_height()
1254
+ opts = lib.validate_match_args(args, lw, lh)
1255
+ if opts['match'] != 'tone' and img.get_base_type() != Gimp.ImageBaseType.RGB:
1256
+ raise ValueError("this image is not RGB; use match 'tone' (or convert it to RGB first)")
1257
+ _ok, ox, oy = layer.get_offsets()
1258
+ pad = opts['surround_px'] if opts['reference'] == 'surround' else 0
1259
+ roi = lib.match_roi(ox, oy, lw, lh, img.get_width(), img.get_height(), pad)
1260
+ if roi is None:
1261
+ raise ValueError('the layer is entirely outside the canvas')
1262
+
1263
+ ledger, _unknown = _ledger_get(img)
1264
+ previous = [
1265
+ (i, f) for i, f in enumerate(layer.get_filters())
1266
+ if ledger.get(f.get_name(), {}).get('tag') == lib.MATCH_TAG
1267
+ and ledger[f.get_name()].get('operation') == f.get_operation_name()
1268
+ ]
1269
+ previous_indexes = [i for i, _f in previous]
1270
+ channels = ['value'] if opts['match'] == 'tone' else ['red', 'green', 'blue']
1271
+
1272
+ source = _match_measure_source(img, layer, roi, previous_indexes, channels)
1273
+ reference = _match_measure_reference(img, layer, roi, previous_indexes, channels, opts)
1274
+ source_pixels = min(count for _m, _s, count in source.values())
1275
+ reference_pixels = min(count for _m, _s, count in reference.values())
1276
+ if source_pixels < lib.MATCH_MIN_PIXELS:
1277
+ raise ValueError(
1278
+ 'the layer has only %d visible pixels to measure (need at least %d)'
1279
+ % (source_pixels, lib.MATCH_MIN_PIXELS)
1280
+ )
1281
+ if reference_pixels < lib.MATCH_MIN_PIXELS:
1282
+ raise ValueError(
1283
+ 'only %d reference pixels were found around the layer (need at least %d); raise '
1284
+ "surround_px or use reference 'below'" % (reference_pixels, lib.MATCH_MIN_PIXELS)
1285
+ )
1286
+
1287
+ plans = []
1288
+ for channel in channels:
1289
+ mu_s, sd_s, _n = source[channel]
1290
+ mu_r, sd_r, _n = reference[channel]
1291
+ t = lib.match_transfer(mu_s, sd_s, mu_r, sd_r, opts['s'])
1292
+ points = lib.match_curve_points(mu_s, t['gain'], t['shift'])
1293
+ plans.append((channel, mu_s, sd_s, mu_r, sd_r, t, points))
1294
+
1295
+ created, replaced, edge = [], [], None
1296
+ try:
1297
+ with _PreservedSelection(img):
1298
+ # Replace, don't stack: this layer's previous match filters go first.
1299
+ if previous:
1300
+ names = set()
1301
+ for _i, f in previous:
1302
+ names.add(f.get_name())
1303
+ replaced.append(f.get_id())
1304
+ f.delete()
1305
+ filters, unknown = _ledger_get(img)
1306
+ for name in names:
1307
+ filters.pop(name, None)
1308
+ _ledger_put(img, filters, unknown, removed=names)
1309
+ # All or nothing: a partial set would leave a colour cast.
1310
+ try:
1311
+ for channel, _ms, _ss, _mr, _sr, _t, points in plans:
1312
+ created.append(_apply_filter(
1313
+ img, {'layer_id': layer.get_id(), 'name': 'Match %s' % channel},
1314
+ 'gimp:curves', {'channel': channel, 'points': points}, 'Match %s' % channel,
1315
+ type_='curves', tag=lib.MATCH_TAG,
1316
+ ))
1317
+ except Exception:
1318
+ _delete_filters(img, created)
1319
+ raise
1320
+ if opts['edge_contract_px'] or opts['edge_feather_px']:
1321
+ mask_created = _match_refine_edges(
1322
+ img, layer, opts['edge_contract_px'], opts['edge_feather_px']
1323
+ )
1324
+ edge = {
1325
+ 'contract_px': opts['edge_contract_px'],
1326
+ 'feather_px': opts['edge_feather_px'],
1327
+ 'mask_created': mask_created,
1328
+ }
1329
+ finally:
1330
+ _drop_proxies(img.get_id())
1331
+
1332
+ def stat(mean, std):
1333
+ return {'mean': round(mean, 2), 'std': round(std, 2)}
1334
+
1335
+ return {
1336
+ 'layer': layer.get_name(),
1337
+ 'layer_id': layer.get_id(),
1338
+ 'match': opts['match'],
1339
+ 'reference': opts['reference'],
1340
+ 'strength': opts['strength'],
1341
+ 'surround_px': opts['surround_px'] if opts['reference'] == 'surround' else None,
1342
+ 'filters': [
1343
+ {'filter_id': c['filter_id'], 'name': c['name'], 'channel': p[0]}
1344
+ for c, p in zip(created, plans)
1345
+ ],
1346
+ 'replaced_filter_ids': replaced,
1347
+ 'measured': {'layer_pixels': source_pixels, 'reference_pixels': reference_pixels},
1348
+ 'channels': {
1349
+ channel: {
1350
+ 'before': stat(mu_s, sd_s),
1351
+ 'after': stat(t['mean_after'], t['std_after']),
1352
+ 'reference': stat(mu_r, sd_r),
1353
+ 'gain': round(t['gain'], 3),
1354
+ }
1355
+ for channel, mu_s, sd_s, mu_r, sd_r, t, _pts in plans
1356
+ },
1357
+ 'edge': edge,
1358
+ }
1359
+
1360
+
1361
+ # ---- retouch ----------------------------------------------------------------------------
1362
+
1363
+ # A generated (parametric) round brush: its size and hardness come from the context, so the
1364
+ # result does not depend on any brush file.
1365
+ _RETOUCH_BRUSH = '2. Hardness 050'
1366
+
1367
+
1368
+ def _retouch_marked(layer):
1369
+ """Whether `layer` is a retouch layer this tool made (carries the retouch parasite)."""
1370
+ return not layer.is_group() and layer.get_parasite(lib.RETOUCH_TAG) is not None
1371
+
1372
+
1373
+ def _retouch_source_tattoo(layer):
1374
+ """The tattoo of the layer a retouch layer was copied from (stored in its parasite as
1375
+ 't:<tattoo>'), or None for a layer without one or with any other marker. A tattoo, unlike a
1376
+ layer id, survives an XCF save and reload, so a checkpoint restore still finds the copy."""
1377
+ para = layer.get_parasite(lib.RETOUCH_TAG)
1378
+ if para is None:
1379
+ return None
1380
+ try:
1381
+ text = bytes(para.get_data()).decode('ascii').strip('\x00')
1382
+ except (TypeError, UnicodeDecodeError):
1383
+ return None
1384
+ if not text.startswith('t:'):
1385
+ return None
1386
+ try:
1387
+ return int(text[2:])
1388
+ except ValueError:
1389
+ return None
1390
+
1391
+
1392
+ def _retouch_layer_above(img, layer):
1393
+ """The retouch layer sitting directly above `layer` among its siblings that was made from
1394
+ `layer` itself, or None."""
1395
+ parent = layer.get_parent()
1396
+ idx = _sibling_index(layer, parent)
1397
+ if not idx:
1398
+ return None
1399
+ siblings = list(parent.get_children()) if parent is not None else list(img.get_layers())
1400
+ above = siblings[idx - 1]
1401
+ if _retouch_marked(above) and _retouch_source_tattoo(above) == layer.get_tattoo():
1402
+ return above
1403
+ return None
1404
+
1405
+
1406
+ def _retouch_make_layer(img, layer):
1407
+ """A copy of `layer` named 'Retouch', directly above it, marked as ours (the parasite names the
1408
+ source layer) and hidden entirely by a black layer mask: only the footprint of each stroke is
1409
+ later revealed, so the composite outside the painted areas is exactly what it was."""
1410
+ ledgered = _ledgered_filter_names_on(img, [layer])
1411
+ if ledgered:
1412
+ raise ValueError(
1413
+ 'cannot retouch %r on a copy: it carries Editmamei filter(s) %s, which a copy cannot '
1414
+ "take along safely. Bake it first (gimp_bake) or use target 'in_place'."
1415
+ % (layer.get_name(), ', '.join(repr(n) for n in sorted(set(ledgered))))
1416
+ )
1417
+ copy = layer.copy()
1418
+ if copy is None:
1419
+ raise lib.OpError('gimp_op_failed', 'GIMP could not duplicate this layer')
1420
+ try:
1421
+ copy.set_name(_unique_layer_name(img, 'Retouch'))
1422
+ parent = layer.get_parent()
1423
+ position = _sibling_index(layer, parent)
1424
+ img.insert_layer(copy, parent, position if position is not None else 0)
1425
+ _assert_layer_attached(img, copy, 'the retouch layer')
1426
+ copy.add_mask(copy.create_mask(Gimp.AddMaskType.BLACK))
1427
+ copy.attach_parasite(Gimp.Parasite.new(
1428
+ lib.RETOUCH_TAG, Gimp.PARASITE_PERSISTENT, ('t:%d' % layer.get_tattoo()).encode('ascii')
1429
+ ))
1430
+ except Exception:
1431
+ _discard_layer(img, copy)
1432
+ raise
1433
+ return copy
1434
+
1435
+
1436
+ def _retouch_paint(method, drawable, source, src_x, src_y, strokes, brush_size, hardness):
1437
+ """One heal or clone stroke on `drawable`, sampling `source` from (src_x, src_y) -- image
1438
+ pixels, for the first stroke point (verified live: both calls take image coordinates, not
1439
+ layer ones). method 'reveal' instead paints white along the same strokes with the same brush
1440
+ (on a layer mask: the footprint becomes visible). The context is pushed and popped so brush
1441
+ state never leaks between calls."""
1442
+ brush = Gimp.Brush.get_by_name(_RETOUCH_BRUSH)
1443
+ if brush is None:
1444
+ raise lib.OpError('gimp_op_failed', 'GIMP has no %r brush' % _RETOUCH_BRUSH)
1445
+ flat = [float(v) for p in strokes for v in p]
1446
+ Gimp.context_push()
1447
+ try:
1448
+ Gimp.context_enable_dynamics(False)
1449
+ Gimp.context_set_brush(brush)
1450
+ Gimp.context_set_brush_size(float(brush_size))
1451
+ Gimp.context_set_brush_hardness(float(hardness))
1452
+ Gimp.context_set_brush_angle(0.0)
1453
+ Gimp.context_set_brush_aspect_ratio(0.0)
1454
+ Gimp.context_set_brush_force(1.0)
1455
+ Gimp.context_set_opacity(100.0)
1456
+ Gimp.context_set_paint_mode(Gimp.LayerMode.NORMAL)
1457
+ Gimp.context_set_antialias(True)
1458
+ if method == 'reveal':
1459
+ Gimp.context_set_foreground(Gegl.Color.new('white'))
1460
+ ok = Gimp.paintbrush_default(drawable, flat)
1461
+ elif method == 'clone':
1462
+ ok = Gimp.clone(drawable, source, Gimp.CloneType.IMAGE, float(src_x), float(src_y), flat)
1463
+ else:
1464
+ ok = Gimp.heal(drawable, source, float(src_x), float(src_y), flat)
1465
+ finally:
1466
+ Gimp.context_pop()
1467
+ if not ok:
1468
+ raise lib.OpError('gimp_op_failed', 'GIMP %s did not paint' % method)
1469
+
1470
+
1471
+ def _retouch_disc_stats(img, drawable, cx, cy, r, inner=None):
1472
+ """(mean, std, pixel count) of the value channel over a disc of radius `r` at (cx, cy), minus a
1473
+ disc of radius `inner` when given; None when that region holds no pixels. Clobbers the
1474
+ selection (the caller holds it)."""
1475
+ Gimp.context_push()
1476
+ try:
1477
+ Gimp.context_set_feather(False)
1478
+ Gimp.context_set_antialias(False)
1479
+ img.select_ellipse(Gimp.ChannelOps.REPLACE, cx - r, cy - r, 2.0 * r, 2.0 * r)
1480
+ if inner is not None:
1481
+ img.select_ellipse(Gimp.ChannelOps.SUBTRACT, cx - inner, cy - inner, 2.0 * inner, 2.0 * inner)
1482
+ # An empty selection reads as "everything" to the histogram, so refuse to measure it.
1483
+ if Gimp.Selection.is_empty(img):
1484
+ return None
1485
+ mean, std, count = _match_hist(drawable, 'value')
1486
+ finally:
1487
+ Gimp.context_pop()
1488
+ return (mean, std, count) if count > 0 else None
1489
+
1490
+
1491
+ def _retouch_pick_source(img, drawable, index, x, y, r, bounds):
1492
+ """(dx, dy, score) of the best source offset for the spot: the candidate disc whose value
1493
+ mean and spread are closest to the ring around the spot."""
1494
+ candidates = lib.spot_candidates(x, y, r, bounds)
1495
+ if not candidates:
1496
+ raise ValueError(
1497
+ 'spot %d at (%g, %g) radius %g: no source area of that size fits inside the layer '
1498
+ '(x %d..%d, y %d..%d); use a smaller radius or heal it by hand with a stroke'
1499
+ % (index, x, y, r, bounds[0], bounds[2] - 1, bounds[1], bounds[3] - 1)
1500
+ )
1501
+ ring = _retouch_disc_stats(
1502
+ img, drawable, x, y, max(lib.RETOUCH_SPOT_RING_OUTER * r, r + 3.0), inner=r
1503
+ )
1504
+ if ring is None:
1505
+ raise ValueError('spot %d at (%g, %g): no surrounding pixels to compare against' % (index, x, y))
1506
+ scored = []
1507
+ for dx, dy in candidates:
1508
+ stats = _retouch_disc_stats(img, drawable, x + dx, y + dy, r)
1509
+ if stats is None:
1510
+ continue
1511
+ scored.append((dx, dy, lib.spot_score(stats[0], stats[1], ring[0], ring[1])))
1512
+ best = lib.spot_pick_best(scored)
1513
+ if best is None:
1514
+ raise ValueError('spot %d at (%g, %g): no usable source area' % (index, x, y))
1515
+ return best
1516
+
1517
+
1518
+ def op_retouch(args):
1519
+ """Heal, clone or spot-heal pixels with a generated round brush.
1520
+
1521
+ Strokes and sources are image pixels (verified live: Gimp.heal / Gimp.clone take image
1522
+ coordinates for both, whatever the layer offset). `target` 'retouch_layer' paints on a copy
1523
+ named 'Retouch' directly above the layer, hidden by a black layer mask that each stroke then
1524
+ reveals (so nothing outside the painted areas changes in the composite); it is reused on a
1525
+ later call while it stays directly above the layer it was made from, and holds the original
1526
+ pixels, so sampling reads them. 'in_place' paints the layer itself. The user's selection is
1527
+ saved, cleared while painting (it would clip the retouch) and restored. A spot_heal picks every
1528
+ spot's source before painting anything."""
1529
+ img = _image(args)
1530
+ if img.get_base_type() == Gimp.ImageBaseType.INDEXED:
1531
+ raise ValueError('cannot retouch an indexed image: convert it to RGB first (gimp_convert_image_mode)')
1532
+ layer = _layer(img, args)
1533
+ opts = lib.validate_retouch_args(args)
1534
+ if layer.is_group():
1535
+ raise ValueError('cannot retouch a layer group: pick a pixel layer inside it')
1536
+ if layer.is_text_layer():
1537
+ raise ValueError('cannot retouch a text layer: rasterize it first (gimp_bake)')
1538
+ if layer.get_lock_content():
1539
+ raise ValueError('layer %r is locked; unlock it first' % layer.get_name())
1540
+ if not layer.get_visible():
1541
+ raise ValueError('layer %r is hidden; make it visible first' % layer.get_name())
1542
+ method = opts['method']
1543
+ on_copy = opts['target'] == 'retouch_layer' and not _retouch_marked(layer)
1544
+ if on_copy:
1545
+ normal = {Gimp.LayerMode.NORMAL, getattr(Gimp.LayerMode, 'NORMAL_LEGACY', Gimp.LayerMode.NORMAL)}
1546
+ if layer.get_opacity() < 100.0 or layer.get_mode() not in normal:
1547
+ raise ValueError(
1548
+ "layer %r is not Normal mode at 100%% opacity, so a Retouch copy above it would "
1549
+ "change the picture outside the painted areas: use target 'in_place'"
1550
+ % layer.get_name()
1551
+ )
1552
+ if layer.get_mask() is not None:
1553
+ raise ValueError(
1554
+ "layer %r has a layer mask, which a Retouch copy cannot take along: use target "
1555
+ "'in_place'" % layer.get_name()
1556
+ )
1557
+ cw, ch = img.get_width(), img.get_height()
1558
+
1559
+ def bounds_of(item):
1560
+ _ok, ox, oy = item.get_offsets()
1561
+ b = lib.retouch_bounds(ox, oy, item.get_width(), item.get_height(), cw, ch)
1562
+ if b is None:
1563
+ raise ValueError('layer %r is entirely outside the canvas' % item.get_name())
1564
+ return b
1565
+
1566
+ bounds = bounds_of(layer)
1567
+ source_layer = None
1568
+ if method == 'clone' and args.get('source_layer_id') is not None:
1569
+ source_layer = _layer(img, {'layer_id': args['source_layer_id']})
1570
+ if source_layer.is_group():
1571
+ raise ValueError('source_layer_id names a layer group: pick a pixel layer')
1572
+ source_bounds = bounds_of(source_layer)
1573
+ else:
1574
+ source_bounds = bounds
1575
+ if method == 'spot_heal':
1576
+ lib.retouch_check_points([[x, y] for x, y, _r in opts['spots']], bounds, 'spot')
1577
+ for i, (x, y, r) in enumerate(opts['spots']):
1578
+ if not lib.spot_candidates(x, y, r, bounds):
1579
+ raise ValueError(
1580
+ 'spot %d at (%g, %g) radius %g: no source area of that size fits inside the '
1581
+ 'layer (x %d..%d, y %d..%d); use a smaller radius or heal it by hand with a '
1582
+ 'stroke' % (i, x, y, r, bounds[0], bounds[2] - 1, bounds[1], bounds[3] - 1)
1583
+ )
1584
+ else:
1585
+ pts = opts['points']
1586
+ dx, dy = opts['source_offset']
1587
+ lib.retouch_check_points(pts, bounds, 'stroke')
1588
+ lib.retouch_check_points([[x + dx, y + dy] for x, y in pts], source_bounds, 'source')
1589
+
1590
+ created = False
1591
+ paint = layer
1592
+ try:
1593
+ with _PreservedSelection(img):
1594
+ Gimp.Selection.none(img)
1595
+ if on_copy:
1596
+ paint = _retouch_layer_above(img, layer) or layer
1597
+ picks = None
1598
+ if method == 'spot_heal':
1599
+ # Every source is chosen before anything is painted (or the copy made): a spot with
1600
+ # no usable source then fails the whole call with nothing changed.
1601
+ picks = [
1602
+ _retouch_pick_source(img, paint, i, x, y, r, bounds)
1603
+ for i, (x, y, r) in enumerate(opts['spots'])
1604
+ ]
1605
+ Gimp.Selection.none(img) # measuring left a selection; it would clip the heal
1606
+ if on_copy and paint is layer:
1607
+ paint = _retouch_make_layer(img, layer)
1608
+ created = True
1609
+ try:
1610
+ mask = paint.get_mask() if _retouch_marked(paint) else None
1611
+ result = {
1612
+ 'layer_id': paint.get_id(), 'layer_name': paint.get_name(),
1613
+ 'created_layer': created, 'method': method, 'target': opts['target'],
1614
+ }
1615
+ if method == 'spot_heal':
1616
+ spots, bbox = [], None
1617
+ for (x, y, r), (dx, dy, score) in zip(opts['spots'], picks):
1618
+ brush = lib.retouch_spot_brush(r)
1619
+ stroke = lib.retouch_cover_stroke(x, y, r, brush, bounds)
1620
+ _retouch_paint(
1621
+ 'heal', paint, paint, stroke[0][0] + dx, stroke[0][1] + dy, stroke,
1622
+ brush, lib.RETOUCH_SPOT_HARDNESS,
1623
+ )
1624
+ if mask is not None:
1625
+ reach = r + brush / 2.0 # the heal footprint: 2.3 x r unless capped
1626
+ _retouch_paint(
1627
+ 'reveal', mask, None, 0, 0, [[x, y]], 2.0 * reach,
1628
+ lib.RETOUCH_REVEAL_HARDNESS,
1629
+ )
1630
+ bbox = lib.union_bbox_xywh(bbox, lib.retouch_painted_bbox(stroke, brush, bounds))
1631
+ spots.append({
1632
+ 'x': x, 'y': y, 'radius': r, 'source_dx': dx, 'source_dy': dy,
1633
+ 'score': round(score, 2),
1634
+ })
1635
+ result['spots'] = spots
1636
+ result['painted_bbox'] = bbox
1637
+ else:
1638
+ pts = opts['points']
1639
+ dx, dy = opts['source_offset']
1640
+ _retouch_paint(
1641
+ method, paint, source_layer if source_layer is not None else paint,
1642
+ pts[0][0] + dx, pts[0][1] + dy, pts, opts['brush_size'], opts['hardness'],
1643
+ )
1644
+ if mask is not None:
1645
+ _retouch_paint(
1646
+ 'reveal', mask, None, 0, 0, pts, opts['brush_size'], opts['hardness']
1647
+ )
1648
+ result['painted_bbox'] = lib.retouch_painted_bbox(pts, opts['brush_size'], bounds)
1649
+ result['brush_size'] = opts['brush_size']
1650
+ result['hardness'] = opts['hardness']
1651
+ except Exception:
1652
+ if created:
1653
+ _discard_layer(img, paint)
1654
+ raise
1655
+ finally:
1656
+ _drop_proxies(img.get_id())
1657
+ return result
1658
+
1659
+
915
1660
  def op_filter(args):
916
1661
  """Stack management: `op` in list | set_visibility | delete. There is deliberately no
917
1662
  `reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
@@ -969,7 +1714,7 @@ def _layer_node_shallow(layer):
969
1714
  `get_children()`, but is still checked rather than trusted blindly -- offsets fall back to
970
1715
  null rather than reporting a wrong position."""
971
1716
  ok, off_x, off_y = layer.get_offsets()
972
- return {
1717
+ node = {
973
1718
  'layer_id': layer.get_id(),
974
1719
  'name': layer.get_name(),
975
1720
  'opacity': layer.get_opacity(),
@@ -981,6 +1726,14 @@ def _layer_node_shallow(layer):
981
1726
  'is_text_layer': layer.is_text_layer(),
982
1727
  'children': [],
983
1728
  }
1729
+ if node['is_text_layer']:
1730
+ # A text layer this engine can't read (an odd unit, a missing font) still describes as
1731
+ # a text layer; it never fails the whole document.
1732
+ try:
1733
+ node.update(_text_fields(layer))
1734
+ except Exception:
1735
+ pass
1736
+ return node
984
1737
 
985
1738
 
986
1739
  def _build_layer_tree(top_layers, max_nodes=MAX_DESCRIBE_LAYER_NODES):
@@ -1222,6 +1975,17 @@ def _refuse_if_masked_filters(img, op_name, before_hint='Rotate, flip or resize'
1222
1975
  ROTATE_DEPENDENT_OPERATIONS = frozenset({'gegl:vignette', 'gegl:motion-blur-linear', 'gegl:dropshadow'})
1223
1976
 
1224
1977
 
1978
+ def _rotation_sensitive(rec):
1979
+ """Whether an arbitrary-angle rotation would misalign this ledger record's filter. A
1980
+ gegl:dropshadow with no offset (outline, outer_glow, or a drop_shadow offset to 0,0) is
1981
+ symmetric about the content, so only the offset ones are."""
1982
+ operation = rec.get('operation')
1983
+ if operation == 'gegl:dropshadow':
1984
+ params = rec.get('params', {})
1985
+ return bool(params.get('x') or params.get('y'))
1986
+ return operation in ROTATE_DEPENDENT_OPERATIONS
1987
+
1988
+
1225
1989
  def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
1226
1990
  if lib.is_right_angle_degrees(degrees):
1227
1991
  return
@@ -1229,7 +1993,7 @@ def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
1229
1993
  live_names = _live_filter_names(img)
1230
1994
  tracked = sorted(
1231
1995
  name for name, rec in filters.items()
1232
- if name in live_names and rec.get('operation') in ROTATE_DEPENDENT_OPERATIONS
1996
+ if name in live_names and _rotation_sensitive(rec)
1233
1997
  )
1234
1998
  if tracked:
1235
1999
  raise ValueError(
@@ -1240,7 +2004,7 @@ def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
1240
2004
  )
1241
2005
 
1242
2006
 
1243
- def _snapshot_effect_transform(img, op_name, transform_fn):
2007
+ def _snapshot_effect_transform(img, op_name, transform_fn, layers=None):
1244
2008
  """Precompute every ledgered effect filter's new params from a SNAPSHOT of the current
1245
2009
  ledger, validate each against this bridge's own field ranges, and raise -- refusing `op_name`
1246
2010
  outright -- if anything would land out of range, ALL BEFORE anything is mutated. Working from
@@ -1255,22 +2019,30 @@ def _snapshot_effect_transform(img, op_name, transform_fn):
1255
2019
  actually changed -- callers apply these with `_apply_planned_effect_transform` AFTER the
1256
2020
  geometry mutation itself. A filter transform_fn has nothing to change for (black_white,
1257
2021
  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."""
2022
+ comment) comes back with the SAME values (by `==`) and is simply absent from the result.
2023
+
2024
+ `layers`, when given, scopes the walk to exactly those layers instead of the whole image --
2025
+ `gimp_transform_layer`'s own use, scoped to the target's subtree (`_layer_subtree`), since
2026
+ that op only ever transforms one layer (or, for a group, its own descendants), never every
2027
+ layer in the image the way the whole-canvas geometry ops below do."""
1259
2028
  filters, _unknown = _ledger_get(img)
1260
2029
  if not filters:
1261
2030
  return {}
1262
- snapshot = {name: (rec['operation'], dict(rec['params'])) for name, rec in filters.items()}
2031
+ snapshot = {
2032
+ name: (rec['operation'], dict(rec['params']), lib.record_type(rec))
2033
+ for name, rec in filters.items()
2034
+ }
1263
2035
  planned = {}
1264
- for layer in _all_layers(img):
2036
+ for layer in (layers if layers is not None else _all_layers(img)):
1265
2037
  for f in layer.get_filters():
1266
2038
  entry = snapshot.get(f.get_name())
1267
2039
  if entry is None or entry[0] != f.get_operation_name():
1268
2040
  continue
1269
- operation, params = entry
1270
- new_params = transform_fn(operation, params, layer)
2041
+ operation, params, type_ = entry
2042
+ new_params = lib.clamp_effect_transform(type_, transform_fn(operation, params, layer))
1271
2043
  if new_params == params:
1272
2044
  continue
1273
- lib.validate_effect_transform(op_name, operation, f.get_name(), new_params)
2045
+ lib.validate_effect_transform(op_name, operation, f.get_name(), new_params, type_)
1274
2046
  planned[f.get_name()] = (operation, new_params)
1275
2047
  return planned
1276
2048
 
@@ -1543,16 +2315,40 @@ def op_preview(args):
1543
2315
  dup.delete()
1544
2316
 
1545
2317
 
1546
- def _channel_stats_from(dup, layer, channels):
2318
+ class _ProxyMaskUnreliable(ValueError):
2319
+ """A downscaled measurement copy cannot measure this mask: it is empty there, or mostly edge
2320
+ pixels. Scaling blurs a thin mask's edges and the pixels under it into their neighbors (a
2321
+ one-pixel line reads as a pale smear), so the caller re-measures at full resolution."""
2322
+
2323
+
2324
+ def _channel_stats_from(dup, layer, channels, mask=None, scaled=False):
2325
+ """Per-channel stats of `layer` (in `dup`). With `mask` (a channel name that `dup` carries --
2326
+ every render here is a duplicate of the document, so its channels came along, scaled or
2327
+ cropped with it), only the pixels the channel selects at 50% or more are counted. `scaled`
2328
+ marks a downscaled copy: a mask that is empty or edge-dominated there (see
2329
+ `lib.MASK_EDGE_FRACTION_MAX`) raises `_ProxyMaskUnreliable`, not a plain refusal."""
1547
2330
  w, h = dup.get_width(), dup.get_height()
1548
2331
  buf = layer.get_buffer()
1549
2332
  rect = Gegl.Rectangle.new(0, 0, w, h)
1550
2333
  rgb = buf.get(rect, 1.0, "R'G'B' u8", Gegl.AbyssPolicy.NONE) if set(channels) - {'luminance'} else None
1551
2334
  lum = buf.get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE) if 'luminance' in channels else None
2335
+ flags = None
2336
+ if mask is not None:
2337
+ ch = _find_channel(dup, mask)
2338
+ if ch is None:
2339
+ raise lib.OpError('gimp_op_failed', 'the render lost the mask channel %r' % mask)
2340
+ raw = bytes(ch.get_buffer().get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE))
2341
+ flags = lib.mask_flags(raw)
2342
+ if scaled and not lib.mask_is_proxy_reliable(raw):
2343
+ raise _ProxyMaskUnreliable('mask %r is too thin to measure on a downscaled copy' % mask)
2344
+ if not any(flags):
2345
+ raise ValueError('mask %r selects no pixels in the measured area' % mask)
1552
2346
  out = {}
1553
2347
  for ch in channels:
1554
- out[ch] = lib.channel_stats(lum if ch == 'luminance' else rgb[('red', 'green', 'blue').index(ch)::3])
1555
- return w, h, out
2348
+ data = lum if ch == 'luminance' else rgb[('red', 'green', 'blue').index(ch)::3]
2349
+ out[ch] = lib.channel_stats(lib.select_masked(data, flags) if flags is not None else data)
2350
+ selected = flags.count(1) if flags is not None else w * h
2351
+ return w, h, out, selected
1556
2352
 
1557
2353
 
1558
2354
  def op_histogram(args):
@@ -1561,13 +2357,22 @@ def op_histogram(args):
1561
2357
  filters). `region`: computed on the (mirrored) proxy when the region maps to at least
1562
2358
  `lib.MIN_PROXY_REGION_PX` on a side there, else a full-res crop -- same "too few proxy
1563
2359
  samples to mean anything" reasoning as `lib.MIN_PROXY_REGION_PX`'s own docstring; exact=True
1564
- always forces the full-res crop regardless of size."""
2360
+ always forces the full-res crop regardless of size.
2361
+
2362
+ `mask` (a channel name): only the pixels that channel selects at 50% or more (an unweighted,
2363
+ thresholded count -- no partial credit for feathered edges) enter the stats, and `pixels` is
2364
+ that count. The channel travels with the measurement copy, so it is read at the copy's scale."""
1565
2365
  img = _image(args)
1566
2366
  channels = args.get('channels') or list(lib.HIST_CHANNELS)
1567
2367
  bad = [c for c in channels if c not in lib.HIST_CHANNELS]
1568
2368
  if bad:
1569
2369
  raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
1570
2370
  exact = bool(args.get('exact', False))
2371
+ mask = args.get('mask')
2372
+ if mask is not None:
2373
+ if not isinstance(mask, str) or not mask:
2374
+ raise ValueError('mask must be a channel name')
2375
+ _require_channel(img, mask)
1571
2376
  region = args.get('region')
1572
2377
  if region:
1573
2378
  lib.validate_region(region, img.get_width(), img.get_height())
@@ -1585,27 +2390,36 @@ def op_histogram(args):
1585
2390
  ph = min(ph, dup.get_height() - py)
1586
2391
  dup.crop(pw, ph, px, py)
1587
2392
  layer = dup.flatten()
1588
- w, h, out = _channel_stats_from(dup, layer, channels)
2393
+ w, h, out, selected = _channel_stats_from(dup, layer, channels, mask, scaled=True)
2394
+ return {'exact': False, 'width': w, 'height': h, 'pixels': selected, 'channels': out,
2395
+ 'unmirrored_filters': unmirrored, 'mask': mask}
2396
+ except _ProxyMaskUnreliable:
2397
+ pass # the mask is finer than the proxy allows: measure the full-res crop below
1589
2398
  finally:
1590
2399
  dup.delete()
1591
- return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out,
1592
- 'unmirrored_filters': unmirrored}
1593
2400
  region = dict(region) # fall through to the full-res path below
1594
2401
  exact = True
1595
2402
 
1596
2403
  unmirrored = []
1597
- if region:
1598
- dup, layer = _region_full_res(img, region)
1599
- elif exact:
1600
- dup, layer = _composite(img)
1601
- else:
2404
+ if not region and not exact:
1602
2405
  dup, unmirrored = _proxy_render(img, 1024)
1603
2406
  layer = dup.get_layers()[0]
1604
- try:
1605
- w, h, out = _channel_stats_from(dup, layer, channels)
1606
- finally:
1607
- dup.delete()
1608
- result = {'exact': exact, 'width': w, 'height': h, 'pixels': w * h, 'channels': out}
2407
+ try:
2408
+ w, h, out, selected = _channel_stats_from(dup, layer, channels, mask, scaled=True)
2409
+ except _ProxyMaskUnreliable:
2410
+ exact = True # a hairline mask lost to the scale-down: measure at full resolution
2411
+ finally:
2412
+ dup.delete()
2413
+ if region or exact:
2414
+ if region:
2415
+ dup, layer = _region_full_res(img, region)
2416
+ else:
2417
+ dup, layer = _composite(img)
2418
+ try:
2419
+ w, h, out, selected = _channel_stats_from(dup, layer, channels, mask)
2420
+ finally:
2421
+ dup.delete()
2422
+ result = {'exact': exact, 'width': w, 'height': h, 'pixels': selected, 'channels': out, 'mask': mask}
1609
2423
  if not region and not exact:
1610
2424
  result['unmirrored_filters'] = unmirrored
1611
2425
  return result
@@ -1649,8 +2463,8 @@ def op_compare(args):
1649
2463
  h = min(ph, dup.get_height() - y)
1650
2464
  dup.crop(w, h, x, y)
1651
2465
  base_layer, after_layer = base.flatten(), after.flatten()
1652
- _bw, _bh, before_stats = _channel_stats_from(base, base_layer, channels)
1653
- _aw, _ah, after_stats = _channel_stats_from(after, after_layer, channels)
2466
+ _bw, _bh, before_stats, _bn = _channel_stats_from(base, base_layer, channels)
2467
+ _aw, _ah, after_stats, _an = _channel_stats_from(after, after_layer, channels)
1654
2468
  deltas = {}
1655
2469
  for ch in channels:
1656
2470
  b, a = before_stats[ch], after_stats[ch]
@@ -1679,12 +2493,12 @@ def op_compare(args):
1679
2493
  raise ValueError('regions mode needs both region_a and region_b')
1680
2494
  dup_a, layer_a = _region_full_res(img, region_a)
1681
2495
  try:
1682
- _wa, _ha, stats_a = _channel_stats_from(dup_a, layer_a, channels)
2496
+ _wa, _ha, stats_a, _na = _channel_stats_from(dup_a, layer_a, channels)
1683
2497
  finally:
1684
2498
  dup_a.delete()
1685
2499
  dup_b, layer_b = _region_full_res(img, region_b)
1686
2500
  try:
1687
- _wb, _hb, stats_b = _channel_stats_from(dup_b, layer_b, channels)
2501
+ _wb, _hb, stats_b, _nb = _channel_stats_from(dup_b, layer_b, channels)
1688
2502
  finally:
1689
2503
  dup_b.delete()
1690
2504
  return {'region_a': stats_a, 'region_b': stats_b, 'proxy': False}
@@ -1731,15 +2545,7 @@ def _export_stripped(dup, path, options=None):
1731
2545
  raise ValueError(
1732
2546
  'export path must end in .jpg, .jpeg, .png, .webp, .tif, or .tiff (got %r)' % ext
1733
2547
  )
1734
- if fmt in ('png', 'tiff'):
1735
- bit_depth = lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
1736
- else:
1737
- bit_depth = 8
1738
- target_precision = (
1739
- Gimp.Precision.U16_NON_LINEAR if bit_depth == 16 else Gimp.Precision.U8_NON_LINEAR
1740
- )
1741
- if dup.get_precision() != target_precision:
1742
- dup.convert_precision(target_precision)
2548
+ _export_precision(dup, options if fmt in ('png', 'tiff') else {})
1743
2549
  try:
1744
2550
  dup.set_metadata(Gimp.Metadata.new())
1745
2551
  except Exception:
@@ -1771,6 +2577,68 @@ def _export_stripped(dup, path, options=None):
1771
2577
  cfg.set_property('image', dup)
1772
2578
  cfg.set_property('file', Gio.File.new_for_path(path))
1773
2579
  _strip_metadata(cfg, fmt)
2580
+ if _has_editors_profile(dup):
2581
+ cfg.set_property('include-color-profile', False)
2582
+ _run_export(proc, cfg, path)
2583
+
2584
+
2585
+ def _has_editors_profile(img):
2586
+ """Whether `img` carries the editor's built-in color profile (or none of its own), which
2587
+ exports leave out: see `lib.profile_is_editors_own`."""
2588
+ profile = img.get_color_profile()
2589
+ if profile is None:
2590
+ return lib.profile_is_editors_own(None)
2591
+ texts = []
2592
+ for getter in ('get_description', 'get_manufacturer', 'get_model', 'get_copyright'):
2593
+ try:
2594
+ texts.append(getattr(profile, getter)())
2595
+ except Exception:
2596
+ pass
2597
+ return lib.profile_is_editors_own(texts)
2598
+
2599
+
2600
+ def _drop_psd_resource(path, resource_id):
2601
+ """Rewrite the PSD at `path` without image resource `resource_id`. Only the header and the
2602
+ resource section are read into memory; the layer and image data are copied through as they
2603
+ are, to a temporary file in the same folder that then replaces the original."""
2604
+ # Dot-prefixed, so nothing that lists the folder for files to serve can pick up a leftover.
2605
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.', prefix='.', suffix='.tmp')
2606
+ try:
2607
+ with os.fdopen(fd, 'wb') as dst, open(path, 'rb') as src:
2608
+ head = src.read(26)
2609
+ color_len = src.read(4)
2610
+ color = src.read(int.from_bytes(color_len, 'big'))
2611
+ res_len = int.from_bytes(src.read(4), 'big')
2612
+ section = src.read(res_len)
2613
+ try:
2614
+ if head[:4] != b'8BPS' or len(section) != res_len:
2615
+ raise ValueError('truncated header')
2616
+ kept = lib.psd_resources_without(section, resource_id)
2617
+ except ValueError:
2618
+ raise lib.OpError('gimp_op_failed', 'could not finish %s' % os.path.basename(path))
2619
+ dst.write(head + color_len + color + len(kept).to_bytes(4, 'big') + kept)
2620
+ shutil.copyfileobj(src, dst, 1 << 20)
2621
+ shutil.copymode(path, tmp) # mkstemp's 0600 would otherwise replace the export's own mode
2622
+ os.replace(tmp, path)
2623
+ except BaseException:
2624
+ try:
2625
+ os.remove(tmp)
2626
+ except OSError:
2627
+ pass
2628
+ raise
2629
+
2630
+
2631
+ def _export_precision(img, options):
2632
+ """Convert `img` to the requested 8/16-bit non-linear precision before it is written, so a
2633
+ 16/32-bit `precision` promotion at open time never leaks into an export that didn't ask for
2634
+ it."""
2635
+ bit_depth = lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
2636
+ target = Gimp.Precision.U16_NON_LINEAR if bit_depth == 16 else Gimp.Precision.U8_NON_LINEAR
2637
+ if img.get_precision() != target:
2638
+ img.convert_precision(target)
2639
+
2640
+
2641
+ def _run_export(proc, cfg, path):
1774
2642
  # A failed write does not raise: run() returns an EXECUTION_ERROR status with GIMP's message
1775
2643
  # as the second value (verified live, GIMP 3.2.6, writing into a missing directory).
1776
2644
  result = proc.run(cfg)
@@ -1785,18 +2653,96 @@ def _export_stripped(dup, path, options=None):
1785
2653
  raise lib.OpError('gimp_op_failed', 'could not write %s: %s' % (name, detail))
1786
2654
 
1787
2655
 
2656
+ def _export_layered(img, path, options):
2657
+ """Write `img` to a layered .psd, from a duplicate so the live document is never touched.
2658
+
2659
+ PSD has no live filters, so on the duplicate: hidden filters are deleted (they render nothing),
2660
+ every other layer's filters are baked in place, and a group carrying its own filter is merged
2661
+ into one raster layer (`lib.layered_bake_plan`; the exporter silently drops a group's filter,
2662
+ verified live on GIMP 3.2.6). Layer masks, opacity, blend modes, visibility and the other
2663
+ groups survive the round trip. Text layers are written as pixels: GIMP's PSD exporter has no
2664
+ text-layer support.
2665
+
2666
+ Photoshop blends layers in gamma space while GIMP blends most modes in linear light, so a
2667
+ layer below 100% opacity, or with a mode other than normal, can look different there
2668
+ (measured: a 60% normal layer drifted ~22 levels on average; multiply ~4; screen and overlay
2669
+ 0). Those layers are listed in the result rather than silently "fixed", since no PSD setting
2670
+ can carry GIMP's blend space.
2671
+
2672
+ Metadata: same rule as `_export_stripped` -- the duplicate's `Gimp.Metadata` is cleared and
2673
+ file-psd-export's own include-exif/xmp/iptc/thumbnail switches are turned off (verified live
2674
+ that with them off the file has no EXIF (1058) or XMP (1060) image resource)."""
2675
+ lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
2676
+ dup = img.duplicate()
2677
+ try:
2678
+ layers = _all_layers(dup)
2679
+ rasterized_text = [l.get_name() for l in layers if l.is_text_layer()]
2680
+ for layer in layers:
2681
+ for f in layer.get_filters():
2682
+ if not f.get_visible():
2683
+ f.delete()
2684
+ nodes = []
2685
+ for layer in layers:
2686
+ parent = layer.get_parent()
2687
+ nodes.append((layer.get_id(), parent.get_id() if parent else None, layer.is_group(),
2688
+ bool(layer.get_filters())))
2689
+ merge_groups, bake_layers = lib.layered_bake_plan(nodes)
2690
+ for layer_id in bake_layers:
2691
+ _bake_one(Gimp.Layer.get_by_id(layer_id))
2692
+ merged_groups = []
2693
+ for group_id in merge_groups:
2694
+ group = Gimp.Layer.get_by_id(group_id)
2695
+ merged_groups.append(group.get_name())
2696
+ group.merge()
2697
+ _export_precision(dup, options)
2698
+ try:
2699
+ dup.set_metadata(Gimp.Metadata.new())
2700
+ except Exception:
2701
+ pass
2702
+
2703
+ proc = Gimp.get_pdb().lookup_procedure('file-psd-export')
2704
+ cfg = proc.create_config()
2705
+ cfg.set_property('image', dup)
2706
+ cfg.set_property('file', Gio.File.new_for_path(path))
2707
+ _strip_metadata(cfg, 'psd')
2708
+ _run_export(proc, cfg, path)
2709
+ # file-psd-export writes the profile whatever include-color-profile says (verified live,
2710
+ # GIMP 3.2.6), so the editor's own is taken out of the written file instead.
2711
+ if _has_editors_profile(dup):
2712
+ _drop_psd_resource(path, lib.PSD_ICC_RESOURCE)
2713
+
2714
+ final = _all_layers(dup)
2715
+ may_blend_differently = [
2716
+ l.get_name() for l in final
2717
+ if l.get_visible() and (l.get_opacity() < 100.0 or l.get_mode() != Gimp.LayerMode.NORMAL)
2718
+ ]
2719
+ return {
2720
+ 'layers': len(final),
2721
+ 'merged_groups': merged_groups,
2722
+ 'rasterized_text': rasterized_text,
2723
+ 'may_blend_differently': may_blend_differently,
2724
+ }
2725
+ finally:
2726
+ dup.delete()
2727
+
2728
+
1788
2729
  def op_export(args):
1789
- """Writes path by extension: .xcf saves the live document, anything else exports a flattened
1790
- copy via `_export_stripped` (metadata stripped; jpeg quality 1-100 default 90; png compression
1791
- 0-9 default 3 -- GIMP's own default of 9 measured far slower for negligible size gain; webp
1792
- quality 0-100 default 90 + lossless; tiff compression, default 'none'; png/tiff bit_depth 8 or
1793
- 16, default 8).
2730
+ """Writes path by extension: .xcf saves the live document, .psd writes a layered copy via
2731
+ `_export_layered` (filters baked, metadata stripped, bit_depth 8 or 16, default 8), anything
2732
+ else exports a flattened copy via `_export_stripped` (metadata stripped; jpeg quality 1-100
2733
+ default 90; png compression 0-9 default 3 -- GIMP's own default of 9 measured far slower for
2734
+ negligible size gain; webp quality 0-100 default 90 + lossless; tiff compression, default
2735
+ 'none'; png/tiff bit_depth 8 or 16, default 8).
2736
+
2737
+ `keep_alpha` (png / webp only) merges the visible layers onto a transparent canvas instead of
2738
+ flattening onto the background color, so the file keeps its transparency.
1794
2739
 
1795
2740
  Flat exports flatten a duplicate first and save that. Handing the live document to
1796
2741
  file_save lets the exporter render the filters itself, which measured 9.3 s for a 24 MP
1797
2742
  JPEG with three curves; flatten (3.0 s) + save of the flat copy (1.0 s) gives the same pixels."""
1798
2743
  img = _image(args)
1799
2744
  path = lib.require(args, 'path')
2745
+ keep_alpha = lib.validate_keep_alpha(args, path)
1800
2746
  if path.lower().endswith('.xcf'):
1801
2747
  # file_save reports a failed write by returning False, not by raising (verified live).
1802
2748
  if not Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path), None):
@@ -1805,8 +2751,19 @@ def op_export(args):
1805
2751
  'could not save %s (check that the folder exists and is writable)'
1806
2752
  % os.path.basename(path),
1807
2753
  )
2754
+ elif os.path.splitext(path)[1].lower() in lib.LAYERED_EXPORT_FORMATS:
2755
+ report = _export_layered(img, path, args)
2756
+ return dict({'path': path, 'bytes': os.path.getsize(path)}, **report)
1808
2757
  else:
1809
- dup, _flat = _composite(img)
2758
+ if keep_alpha:
2759
+ dup = img.duplicate()
2760
+ try:
2761
+ _merge_visible_onto_transparent(dup)
2762
+ except Exception:
2763
+ dup.delete()
2764
+ raise
2765
+ else:
2766
+ dup, _flat = _composite(img)
1810
2767
  try:
1811
2768
  _export_stripped(dup, path, args)
1812
2769
  finally:
@@ -1814,14 +2771,99 @@ def op_export(args):
1814
2771
  return {'path': path, 'bytes': os.path.getsize(path)}
1815
2772
 
1816
2773
 
1817
- def _replace_named_channel(img, name, w, h):
1818
- """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."""
1820
- for existing in img.get_channels():
1821
- if existing.get_name() == name:
1822
- img.remove_channel(existing)
1823
- ch = Gimp.Channel.new(img, name, w, h, 50.0, Gegl.Color.new('black'))
2774
+ def _merge_visible_onto_transparent(dup):
2775
+ """Merge `dup`'s visible layers onto a transparent canvas-sized layer, in place, and return the
2776
+ one resulting RGBA layer (canvas-sized, at 0,0). Unlike `flatten`, nothing is composited onto the
2777
+ context background, so empty areas stay transparent. An indexed or grayscale `dup` is converted
2778
+ to RGB first."""
2779
+ if dup.get_base_type() != Gimp.ImageBaseType.RGB:
2780
+ dup.convert_rgb()
2781
+ if not any(item.get_visible() for item in dup.get_layers()):
2782
+ raise ValueError('the image has no visible layer to render')
2783
+ w, h = dup.get_width(), dup.get_height()
2784
+ bg = Gimp.Layer.new(dup, 'alpha-base', w, h, Gimp.ImageType.RGBA_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
2785
+ dup.insert_layer(bg, None, len(dup.get_layers()))
2786
+ bg.fill(Gimp.FillType.TRANSPARENT)
2787
+ return dup.merge_visible_layers(Gimp.MergeType.CLIP_TO_IMAGE)
2788
+
2789
+
2790
+ _INVERT_BYTES = bytes(255 - i for i in range(256))
2791
+
2792
+
2793
+ def op_render_region(args):
2794
+ """Bridge-only (no tool): the pixels of `box` [x, y, w, h] (image pixels, clamped to the canvas)
2795
+ as an 8-bit sRGB RGBA PNG at `out_path`, at full resolution, never downscaled. The source is the visible composite, or with `layer` / `layer_id` that one
2796
+ layer rendered as if fully visible at 100% opacity in Normal mode (its mask and filters included,
2797
+ as `render_layer` does). `hole_channel` names a channel whose value becomes transparency: output
2798
+ alpha is multiplied by 255 - value, so the channel's selected pixels turn transparent. Works on a
2799
+ temporary image that is always deleted; refuses a box past 4096 px a side or 16 megapixels."""
2800
+ img = _image(args)
2801
+ x, y, w, h = lib.validate_render_box(args.get('box'), img.get_width(), img.get_height())
2802
+ out_path = lib.require(args, 'out_path')
2803
+ if os.path.splitext(out_path)[1].lower() != '.png':
2804
+ raise ValueError('out_path must end in .png')
2805
+ hole_alpha = None
2806
+ if args.get('hole_channel') is not None:
2807
+ channel = _require_channel(img, args['hole_channel'])
2808
+ hole = channel.get_buffer().get(Gegl.Rectangle.new(x, y, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
2809
+ hole_alpha = bytes(hole).translate(_INVERT_BYTES)
2810
+
2811
+ use_layer = args.get('layer_id') is not None or bool(args.get('layer'))
2812
+ if use_layer:
2813
+ layer = _layer(img, args)
2814
+ if layer.is_group():
2815
+ raise ValueError('cannot render a layer group: pick a pixel layer')
2816
+ tmp = Gimp.Image.new(w, h, Gimp.ImageBaseType.RGB)
2817
+ else:
2818
+ tmp = img.duplicate()
2819
+ try:
2820
+ if use_layer:
2821
+ nl = Gimp.Layer.new_from_drawable(layer, tmp)
2822
+ tmp.insert_layer(nl, None, 0)
2823
+ _ok, ox, oy = layer.get_offsets()
2824
+ nl.set_offsets(ox - x, oy - y)
2825
+ nl.set_visible(True)
2826
+ nl.set_opacity(100.0)
2827
+ nl.set_mode(Gimp.LayerMode.NORMAL)
2828
+ else:
2829
+ tmp.crop(w, h, x, y)
2830
+ merged = _merge_visible_onto_transparent(tmp)
2831
+ if tmp.get_color_profile() is not None:
2832
+ tmp.convert_color_profile(
2833
+ Gimp.ColorProfile.new_rgb_srgb(), Gimp.ColorRenderingIntent.RELATIVE_COLORIMETRIC, True
2834
+ )
2835
+ if tmp.get_width() != w or tmp.get_height() != h or merged.get_width() != w or merged.get_height() != h:
2836
+ raise lib.OpError('gimp_op_failed', 'the rendered region came out the wrong size')
2837
+ if hole_alpha is not None:
2838
+ mask = merged.create_mask(Gimp.AddMaskType.WHITE)
2839
+ merged.add_mask(mask)
2840
+ buf = mask.get_buffer()
2841
+ # "Y u8" (linear): a mask multiplies alpha by its linear value, so 255 - v lands on
2842
+ # alpha as exactly (255 - v) / 255 -- the primed format would apply a gamma curve.
2843
+ buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y u8", hole_alpha)
2844
+ buf.flush()
2845
+ mask.update(0, 0, w, h)
2846
+ merged.remove_mask(Gimp.MaskApplyMode.APPLY)
2847
+ _export_stripped(tmp, out_path)
2848
+ return {'path': out_path, 'width': w, 'height': h, 'box': [x, y, w, h]}
2849
+ finally:
2850
+ tmp.delete()
2851
+
2852
+
2853
+ def _replace_named_channel(img, name, w, h):
2854
+ """Remove any existing channel called `name` and insert a fresh, black-filled one of the
2855
+ image's own size -- `op_create_mask`'s replace-by-name semantics. Restores the image's
2856
+ selected layers afterward: `insert_channel` changes them as a side effect (verified live, the
2857
+ same class of surprise `op_place_image`'s own comment documents for `insert_layer`), which
2858
+ would otherwise silently retarget a later layer-defaulting call (e.g. `gimp_add_adjustment`
2859
+ with no `layer`) at whatever layer a channel happened to insert next to."""
2860
+ original_selection = img.get_selected_layers()
2861
+ for existing in img.get_channels():
2862
+ if existing.get_name() == name:
2863
+ img.remove_channel(existing)
2864
+ ch = Gimp.Channel.new(img, name, w, h, 50.0, Gegl.Color.new('black'))
1824
2865
  img.insert_channel(ch, None, 0)
2866
+ img.set_selected_layers(original_selection)
1825
2867
  return ch
1826
2868
 
1827
2869
 
@@ -1856,6 +2898,31 @@ def _mask_name_in_use(img, name):
1856
2898
  return any(rec.get('params', {}).get('mask') == name for rec in filters.values())
1857
2899
 
1858
2900
 
2901
+ def _refuse_mask_in_use(img, name):
2902
+ if _mask_name_in_use(img, name):
2903
+ raise ValueError(
2904
+ 'mask %r is already used by an existing filter; delete that filter or use a '
2905
+ 'different name' % name
2906
+ )
2907
+
2908
+
2909
+ def _find_channel(img, name):
2910
+ for ch in img.get_channels():
2911
+ if ch.get_name() == name:
2912
+ return ch
2913
+ return None
2914
+
2915
+
2916
+ def _require_channel(img, name):
2917
+ """`_find_channel`, refusing a miss with the one neutral message every caller (the filter
2918
+ mask lookup and every gimp_select-family channel reference) shares -- a channel can come from
2919
+ gimp_select, gimp_layer_mask op=create, or load_mask, so no single tool name belongs here."""
2920
+ ch = _find_channel(img, name)
2921
+ if ch is None:
2922
+ raise ValueError('no channel named %r' % name)
2923
+ return ch
2924
+
2925
+
1859
2926
  def op_create_mask(args):
1860
2927
  """Geometric mask -> a named channel (replacing any same-named channel not already in use as
1861
2928
  a filter's mask -- see `_mask_name_in_use`), leaving the selection cleared afterward.
@@ -1873,15 +2940,14 @@ def op_create_mask(args):
1873
2940
  of how this function exits, rather than manually saving and restoring each property by hand.
1874
2941
  The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
1875
2942
  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.
2943
+ RGB_LINEAR, and leaving the context at whatever it already was all produce the same
2944
+ byte-identical ramp (an exact linear ramp -- x=128 reads 128) when read back with `"Y' u8"`.
2945
+ What DOES control the crossing point is the buffer format every reader of this channel
2946
+ (`_channel_coverage`, and the selection copy above) uses -- `"Y' u8"` (perceptual/gamma-encoded,
2947
+ matching the channel's own storage), not `'Y u8'` (linear light, which compresses the readback
2948
+ toward black, e.g. the geometric midpoint reading ~55 instead of ~128). RGB_PERCEPTUAL is kept
2949
+ set here anyway as the explicit, correct-if-it-ever-starts-mattering choice for a caller who
2950
+ thinks in 0-255 terms, not because it changes anything measured today.
1885
2951
 
1886
2952
  `feather_px` has no effect here (a gradient is already a continuous ramp, nothing to feather);
1887
2953
  `invert` swaps which end is black vs white."""
@@ -1890,74 +2956,98 @@ def op_create_mask(args):
1890
2956
  if type_ not in lib.MASK_TYPES:
1891
2957
  raise ValueError('type must be one of %s' % sorted(lib.MASK_TYPES))
1892
2958
  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
- )
2959
+ _refuse_mask_in_use(img, name)
1898
2960
  invert = bool(args.get('invert', False))
1899
2961
  feather_px = lib.validate_feather_px(args.get('feather_px', 0))
1900
2962
  w, h = img.get_width(), img.get_height()
1901
2963
 
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
2964
+ try:
2965
+ if type_ in ('rectangle', 'ellipse'):
2966
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
2967
+ width, height = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
2968
+ if type_ == 'rectangle':
2969
+ img.select_rectangle(Gimp.ChannelOps.REPLACE, x, y, width, height)
2970
+ else:
2971
+ img.select_ellipse(Gimp.ChannelOps.REPLACE, x, y, width, height)
2972
+ if invert:
2973
+ Gimp.Selection.invert(img)
2974
+ if feather_px > 0:
2975
+ Gimp.Selection.feather(img, feather_px)
2976
+ ch = _replace_named_channel(img, name, w, h)
2977
+ # "Y' u8" (perceptual), not "Y u8" (linear) -- see `_channel_coverage`'s comment. A
2978
+ # hard selection is 0/255 either way, but a FEATHERED one has a real falloff whose
2979
+ # shape this format choice controls.
2980
+ sel_data = img.get_selection().get_buffer().get(
2981
+ Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE
2982
+ )
2983
+ ch_buf = ch.get_buffer()
2984
+ ch_buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", sel_data)
2985
+ ch_buf.flush()
2986
+ ch.update(0, 0, w, h)
1938
2987
  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).
2988
+ ch = _replace_named_channel(img, name, w, h)
2989
+ _paint_gradient_mask(ch, type_, invert, args, w, h)
2990
+ finally:
2991
+ Gimp.Selection.none(img) # a mask is referenced by NAME at filter-creation time, not by
2992
+ # staying the active selection -- leaving it selected let the NEXT unmasked filter
2993
+ # silently inherit it (`_append_masked` also clears defensively, but the fix belongs
2994
+ # here too).
1956
2995
  _drop_proxies(img.get_id())
1957
2996
  selected, fraction = _channel_coverage(ch, w, h)
1958
2997
  return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
1959
2998
 
1960
2999
 
3000
+ def _paint_gradient_mask(ch, type_, invert, args, w, h):
3001
+ """Paint a `type_` ('gradient_linear' | 'gradient_radial') black-to-white ramp directly into
3002
+ channel `ch`. `gegl:linear-gradient`/`gegl:radial-gradient` are NOT usable as drawable filters
3003
+ (verified live: their pspecs return None, `DrawableFilter.new` fails) -- the working route is
3004
+ `Drawable.edit_gradient_fill` (verified live). `Gimp.context_push`/`context_pop` bracket every
3005
+ context change (foreground/background/gradient/blend space) so the session's ambient context
3006
+ -- which the user's own GUI or a later call might depend on -- is restored exactly regardless
3007
+ of how this function exits, rather than manually saving and restoring each property by hand.
3008
+ The gradient blend color space is explicitly set to RGB_PERCEPTUAL, but verified live that this
3009
+ has NO effect on a Channel fill's actual output either way: forcing RGB_PERCEPTUAL, forcing
3010
+ RGB_LINEAR, and leaving the context at whatever it already was all produce the same
3011
+ byte-identical ramp (an exact linear ramp -- x=128 reads 128) when read back with `"Y' u8"`.
3012
+ What DOES control the crossing point is the buffer format every reader of this channel
3013
+ (`_channel_coverage`) uses -- `"Y' u8"` (perceptual/gamma-encoded, matching the channel's own
3014
+ storage), not `'Y u8'` (linear light, which compresses the readback toward black, e.g. the
3015
+ geometric midpoint reading ~55 instead of ~128). RGB_PERCEPTUAL is kept set here anyway as the
3016
+ explicit, correct-if-it-ever-starts-mattering choice for a caller who thinks in 0-255 terms, not
3017
+ because it changes anything measured today.
3018
+
3019
+ No feather here (a gradient is already a continuous ramp, nothing to feather); `invert` swaps
3020
+ which end is black vs white. Shared by `op_create_mask` and `op_select`'s own gradient_linear/
3021
+ gradient_radial modes."""
3022
+ black, white = ('white', 'black') if invert else ('black', 'white')
3023
+ # A gradient fill paints `start_color` at (x1,y1) and `end_color` at (x2,y2). For
3024
+ # gradient_linear those points are the caller's own start/end -- black-at-x1 ramps toward
3025
+ # white-at-x2 exactly as given (verified live). For gradient_radial (x1,y1) is always the
3026
+ # CENTER and (x2,y2) the radius edge, and the intuitive reading of a radial mask is a
3027
+ # spotlight -- full effect (white) at the center, fading to none (black) at the edge -- so the
3028
+ # two colors are swapped relative to the linear case.
3029
+ if type_ == 'gradient_linear':
3030
+ start_color, end_color = black, white
3031
+ x1, y1 = float(args.get('x1', 0)), float(args.get('y1', 0))
3032
+ x2, y2 = float(args.get('x2', w)), float(args.get('y2', 0))
3033
+ gtype = Gimp.GradientType.LINEAR
3034
+ else:
3035
+ start_color, end_color = white, black
3036
+ cx, cy = float(args.get('cx', w / 2.0)), float(args.get('cy', h / 2.0))
3037
+ radius = float(args.get('radius', min(w, h) / 2.0))
3038
+ x1, y1, x2, y2 = cx, cy, cx + radius, cy
3039
+ gtype = Gimp.GradientType.RADIAL
3040
+ Gimp.context_push()
3041
+ try:
3042
+ Gimp.context_set_foreground(Gegl.Color.new(start_color))
3043
+ Gimp.context_set_background(Gegl.Color.new(end_color))
3044
+ Gimp.context_set_gradient_fg_bg_rgb()
3045
+ Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
3046
+ ch.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
3047
+ finally:
3048
+ Gimp.context_pop()
3049
+
3050
+
1961
3051
  def op_describe_operation(args):
1962
3052
  """Probe: `list_properties()` (name, type, min, max, default) for a named GEGL/GIMP
1963
3053
  operation, via a transient `DrawableFilter` on a throwaway 1x1 image that is never applied
@@ -2119,9 +3209,9 @@ def _layer_ancestors(layer):
2119
3209
 
2120
3210
  def _refuse_if_masked_filters_on(img, op_name, layer):
2121
3211
  """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.
3212
+ itself, its own subtree (`_layer_subtree` -- transforming a group takes its children with
3213
+ it), and its containing groups (`_layer_ancestors`) -- transforming `layer` cannot misalign a
3214
+ masked filter that lives on some OTHER, unrelated part of the image.
2125
3215
 
2126
3216
  Duplicate-name classification runs over the WHOLE image's live filters, not just this scope: a
2127
3217
  name is ambiguous the moment it appears on more than one live filter ANYWHERE in the image,
@@ -2129,7 +3219,7 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2129
3219
  elsewhere hiding behind an unmasked one's ledger record in this scope (or the reverse) would
2130
3220
  otherwise slip through unnoticed. The refusal itself still only fires for a name that actually
2131
3221
  appears in `layer`'s own scope, so a masked filter on a completely unrelated layer never blocks
2132
- this move."""
3222
+ this transform."""
2133
3223
  scope = set(_layer_subtree(layer)) | set(_layer_ancestors(layer))
2134
3224
  scoped_names = {f.get_name() for l in scope for f in l.get_filters()}
2135
3225
  filters = _prune_stale_ledger_records(img)
@@ -2157,8 +3247,8 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2157
3247
  reasons.append(
2158
3248
  'filter(s) %s on this layer, or a group containing it, were not created by '
2159
3249
  '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'
3250
+ 'they are masked cannot be checked -- delete them first, or transform the layer '
3251
+ 'before adding them'
2162
3252
  % ', '.join(repr(n) for n in unrecognized)
2163
3253
  )
2164
3254
  if duplicated:
@@ -2182,8 +3272,8 @@ def _refuse_if_masked_filters_on(img, op_name, layer):
2182
3272
  raise ValueError(
2183
3273
  '%s would misalign the masked adjustment(s) %s: a filter\'s mask does not travel with '
2184
3274
  '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.'
3275
+ 'contains it. Transform the layer before adding masked adjustments, or delete those '
3276
+ 'filters first and re-create them afterwards.'
2187
3277
  % (op_name, ', '.join(repr(n) for n in masked))
2188
3278
  )
2189
3279
 
@@ -2196,7 +3286,8 @@ def _resolve_parent_group(img, args):
2196
3286
  parent_id = args.get('parent_group')
2197
3287
  if parent_id is None:
2198
3288
  return None
2199
- parent = Gimp.Layer.get_by_id(int(parent_id))
3289
+ gid = lib.gimp_id(parent_id)
3290
+ parent = Gimp.Layer.get_by_id(gid) if gid is not None else None
2200
3291
  owner = _owning_image(parent)
2201
3292
  if parent is None or owner is None or owner.get_id() != img.get_id():
2202
3293
  raise ValueError('no layer with id %s on image %s' % (parent_id, img.get_id()))
@@ -2301,7 +3392,7 @@ _LAYER_CAPABLE_BASE_TYPES = {
2301
3392
  }
2302
3393
 
2303
3394
 
2304
- def _layer_type_for(img):
3395
+ def _layer_type_for(img, tool='gimp_layer op=create'):
2305
3396
  """The `Gimp.ImageType` a new layer should use so it matches `img`'s own color model instead
2306
3397
  of relying on GIMP's own silent coercion (verified live: an RGBA layer inserted into a
2307
3398
  grayscale image is quietly converted to GRAYA on insert -- this asks for the right type up
@@ -2310,7 +3401,7 @@ def _layer_type_for(img):
2310
3401
  layer_type = _LAYER_CAPABLE_BASE_TYPES.get(base)
2311
3402
  if layer_type is None:
2312
3403
  raise ValueError(
2313
- 'gimp_layer op=create does not support %s images -- only RGB and grayscale' % base.value_nick
3404
+ '%s does not support %s images -- only RGB and grayscale' % (tool, base.value_nick)
2314
3405
  )
2315
3406
  return layer_type
2316
3407
 
@@ -2479,19 +3570,6 @@ def _op_layer_set(img, args):
2479
3570
  return {'layer_id': layer.get_id(), 'name': layer.get_name(), **changed}
2480
3571
 
2481
3572
 
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
3573
  def _op_layer_reorder(img, args):
2496
3574
  layer = _layer(img, args)
2497
3575
  parent = _resolve_parent_group(img, args) if 'parent_group' in args else layer.get_parent()
@@ -2604,7 +3682,6 @@ LAYER_OPS = {
2604
3682
  'duplicate': _op_layer_duplicate,
2605
3683
  'select': _op_layer_select,
2606
3684
  'set': _op_layer_set,
2607
- 'move': _op_layer_move,
2608
3685
  'reorder': _op_layer_reorder,
2609
3686
  'merge_down': _op_layer_merge_down,
2610
3687
  'flatten': _op_layer_flatten,
@@ -2620,6 +3697,594 @@ def op_layer(args):
2620
3697
  return fn(img, args)
2621
3698
 
2622
3699
 
3700
+ # ---- gimp_text / gimp_inspect what=fonts ------------------------------------------------------
3701
+ #
3702
+ # A text layer stays a live Gimp.TextLayer: text, font, size, colour and justification are
3703
+ # properties of the layer, re-rendered by GIMP on every change. Sizes are exchanged in points and
3704
+ # stored in pixels (pt * the image's vertical resolution / 72). A rendered layer past the engine's
3705
+ # size cap is refused: a new layer is discarded, an edit is rolled back to the previous values.
3706
+
3707
+ _JUSTIFICATIONS = {
3708
+ 'left': Gimp.TextJustification.LEFT,
3709
+ 'right': Gimp.TextJustification.RIGHT,
3710
+ 'center': Gimp.TextJustification.CENTER,
3711
+ 'fill': Gimp.TextJustification.FILL,
3712
+ }
3713
+
3714
+
3715
+ def _font_list():
3716
+ """Every installed font as `{name: Gimp.Font}`. A freshly started GIMP can report an empty
3717
+ list while its font cache is still loading, so an empty result is retried briefly."""
3718
+ for _ in range(20):
3719
+ fonts = Gimp.fonts_get_list('')
3720
+ if fonts:
3721
+ return {f.get_name(): f for f in fonts}
3722
+ time.sleep(0.25)
3723
+ raise lib.OpError('gimp_op_failed', 'GIMP reports no installed fonts (the font list is empty)')
3724
+
3725
+
3726
+ def op_fonts(args):
3727
+ """`gimp_inspect what=fonts`: installed font names, sorted, optionally narrowed by a
3728
+ case-insensitive substring `filter`, capped (`total` counts every match)."""
3729
+ flt = args.get('filter')
3730
+ if flt is not None and not isinstance(flt, str):
3731
+ raise ValueError('filter must be a string')
3732
+ installed = list(_font_list())
3733
+ names, total = lib.list_fonts(installed, flt)
3734
+ return {'fonts': names, 'total': total, 'returned': len(names), 'truncated': len(names) < total,
3735
+ 'default': lib.pick_default_font(installed)}
3736
+
3737
+
3738
+ def _image_ppi(img):
3739
+ ok, _xres, yres = img.get_resolution()
3740
+ return yres if ok else None
3741
+
3742
+
3743
+ def _text_font_size_pt(layer):
3744
+ size, unit = layer.get_font_size()
3745
+ return lib.unit_size_to_pt(
3746
+ size, unit.get_id() == Gimp.Unit.pixel().get_id(), unit.get_factor(),
3747
+ _image_ppi(layer.get_image()),
3748
+ )
3749
+
3750
+
3751
+ def _text_color_rgb(layer):
3752
+ r, g, b, _a = layer.get_color().get_rgba()
3753
+ return {'red': lib.linear_to_srgb_u8(r), 'green': lib.linear_to_srgb_u8(g),
3754
+ 'blue': lib.linear_to_srgb_u8(b)}
3755
+
3756
+
3757
+ def _has_markup(layer):
3758
+ """Whether the layer's text is stored as markup (per-character styling from the GUI), in
3759
+ which case it has no plain text."""
3760
+ return bool(layer.get_markup())
3761
+
3762
+
3763
+ def _text_fields(layer):
3764
+ """A text layer's own state, in gimp_text's units (points, 0-255 colour, ps_text-style
3765
+ alignment names). Shared by `describe`'s layer nodes and gimp_text's results. The text is
3766
+ capped for reporting (`text_length` is the full length); a markup layer reports '' with
3767
+ `has_markup`."""
3768
+ font = layer.get_font()
3769
+ has_markup = _has_markup(layer)
3770
+ text, length, truncated = lib.text_for_report('' if has_markup else layer.get_text())
3771
+ return {
3772
+ 'text': text,
3773
+ 'text_length': length,
3774
+ 'text_truncated': truncated,
3775
+ 'has_markup': has_markup,
3776
+ 'font': font.get_name() if font is not None else None,
3777
+ 'font_size': _text_font_size_pt(layer),
3778
+ 'color': _text_color_rgb(layer),
3779
+ 'alignment': lib.alignment_name(layer.get_justification().value_nick),
3780
+ }
3781
+
3782
+
3783
+ def _text_record(layer):
3784
+ ok, off_x, off_y = layer.get_offsets()
3785
+ record = {'layer_id': layer.get_id(), 'name': layer.get_name()}
3786
+ record.update(_text_fields(layer))
3787
+ ppi = _image_ppi(layer.get_image())
3788
+ record['ppi'] = ppi
3789
+ record['font_size_px'] = round(_text_size_px(layer), 1)
3790
+ record['bounds'] = {
3791
+ 'x': off_x if ok else None, 'y': off_y if ok else None,
3792
+ 'width': layer.get_width(), 'height': layer.get_height(),
3793
+ }
3794
+ return record
3795
+
3796
+
3797
+ def _set_text_color(layer, rgb):
3798
+ color = Gegl.Color.new('black')
3799
+ color.set_rgba(lib.srgb_u8_to_linear(rgb[0]), lib.srgb_u8_to_linear(rgb[1]),
3800
+ lib.srgb_u8_to_linear(rgb[2]), 1.0)
3801
+ layer.set_color(color)
3802
+
3803
+
3804
+ _TEXT_PROBE_PX = 8.0
3805
+
3806
+
3807
+ def _check_estimated_text_size(text, font, size_px, img, layer=None):
3808
+ """Refuse text that would render past the size cap BEFORE rendering it at full size: lay it
3809
+ out once on a throwaway layer at a tiny size and scale the measured extent up (with headroom,
3810
+ plus `layer`'s own letter/line spacing and indent, which do not scale), so an absurd request
3811
+ never makes GIMP allocate and draw an enormous layer just to be refused. Fails closed: when
3812
+ no estimate can be made, the request is refused."""
3813
+ if font is None:
3814
+ font = _resolved_font(_font_list(), None)
3815
+ probe = Gimp.TextLayer.new(img, text, font, _TEXT_PROBE_PX, Gimp.Unit.pixel())
3816
+ if probe is None:
3817
+ raise lib.OpError('gimp_op_failed', 'GIMP could not lay out the text to check its size')
3818
+ try:
3819
+ spacing = {}
3820
+ if layer is not None:
3821
+ spacing = {
3822
+ 'letter_spacing': layer.get_letter_spacing(),
3823
+ 'line_spacing': layer.get_line_spacing(),
3824
+ 'indent': layer.get_indent(),
3825
+ }
3826
+ width, height = lib.estimate_text_extent(
3827
+ probe.get_width(), probe.get_height(), _TEXT_PROBE_PX, size_px,
3828
+ chars=len(text), lines=text.count('\n') + 1, **spacing
3829
+ )
3830
+ finally:
3831
+ probe.delete()
3832
+ lib.check_estimated_text_size(width, height)
3833
+
3834
+
3835
+ def _text_size_px(layer):
3836
+ """The layer's font size in pixels, whichever unit it is stored in."""
3837
+ size, unit = layer.get_font_size()
3838
+ if unit.get_id() == Gimp.Unit.pixel().get_id():
3839
+ return size
3840
+ return lib.pt_to_px(_text_font_size_pt(layer), _image_ppi(layer.get_image()))
3841
+
3842
+
3843
+ def _text_state(layer):
3844
+ return {
3845
+ 'text': layer.get_text(), 'font': layer.get_font(), 'size': layer.get_font_size(),
3846
+ 'color': layer.get_color(), 'justification': layer.get_justification(),
3847
+ }
3848
+
3849
+
3850
+ def _same_size(a, b):
3851
+ return a[0] == b[0] and a[1].get_id() == b[1].get_id()
3852
+
3853
+
3854
+ def _text_restore(layer, state):
3855
+ """Best-effort rollback to a `_text_state` snapshot, touching only what changed (each setter
3856
+ re-renders the layer). When the size grew it is restored first, so the font and text go back
3857
+ at the smaller size. Never raises (it runs while the original failure is propagating)."""
3858
+ try:
3859
+ current_font = layer.get_font()
3860
+ size_changed = not _same_size(layer.get_font_size(), state['size'])
3861
+ shrink_first = size_changed and _text_size_px(layer) > _size_px_of(layer, state['size'])
3862
+ if shrink_first:
3863
+ layer.set_font_size(state['size'][0], state['size'][1])
3864
+ if state['font'] is not None and (
3865
+ current_font is None or current_font.get_name() != state['font'].get_name()
3866
+ ):
3867
+ layer.set_font(state['font'])
3868
+ if state['text'] is not None and layer.get_text() != state['text']:
3869
+ layer.set_text(state['text'])
3870
+ if size_changed and not shrink_first:
3871
+ layer.set_font_size(state['size'][0], state['size'][1])
3872
+ layer.set_color(state['color'])
3873
+ if layer.get_justification() != state['justification']:
3874
+ layer.set_justification(state['justification'])
3875
+ except Exception:
3876
+ pass
3877
+
3878
+
3879
+ def _size_px_of(layer, size):
3880
+ """A stored (value, unit) font size in pixels."""
3881
+ value, unit = size
3882
+ if unit.get_id() == Gimp.Unit.pixel().get_id():
3883
+ return value
3884
+ pt = lib.unit_size_to_pt(value, False, unit.get_factor(), _image_ppi(layer.get_image()))
3885
+ return lib.pt_to_px(pt, _image_ppi(layer.get_image()))
3886
+
3887
+
3888
+ def _resolved_font(fonts, font_name):
3889
+ if font_name is None:
3890
+ return fonts[lib.pick_default_font(list(fonts))]
3891
+ name, _how = lib.resolve_font(font_name, list(fonts))
3892
+ return fonts[name]
3893
+
3894
+
3895
+ def _text_extents(font, size_px, text):
3896
+ """GIMP's own dynamic-mode layout extents of `text` at `size_px`."""
3897
+ return Gimp.text_get_extents_font(text, size_px, font)
3898
+
3899
+
3900
+ def _text_line_metrics(layer):
3901
+ """(single_line_height, pitch) in px for the layer's font and size: the pitch is the line
3902
+ height plus the layer's line spacing, measured from GIMP's extents of one line and of a
3903
+ block of `TEXT_LINE_PITCH_SAMPLE_LINES` (averaged, so a fractional line height does not
3904
+ round into a whole-pixel error that grows with the line count)."""
3905
+ font, size_px = layer.get_font(), _text_size_px(layer)
3906
+ n = lib.TEXT_LINE_PITCH_SAMPLE_LINES
3907
+ one = float(_text_extents(font, size_px, 'Ag').height)
3908
+ many = float(_text_extents(font, size_px, '\n'.join(['Ag'] * n)).height)
3909
+ return one, max(1.0, (many - one) / (n - 1) + layer.get_line_spacing())
3910
+
3911
+
3912
+ def _text_ink_extent(layer):
3913
+ """(index of the last layer row holding any ink, -1 for a blank layer; whether the last
3914
+ column holds any ink)."""
3915
+ w, h = layer.get_width(), layer.get_height()
3916
+ data = bytes(layer.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, 'A u8', Gegl.AbyssPolicy.NONE))
3917
+ trimmed = data.rstrip(b'\x00')
3918
+ bottom = (len(trimmed) - 1) // w if trimmed else -1
3919
+ return bottom, any(data[w - 1::w])
3920
+
3921
+
3922
+ def _text_set_box(layer, width, height):
3923
+ """Switch the layer to GIMP's fixed box mode at `width` x `height` (`TextLayer.resize`;
3924
+ verified live on 3.2.6: the text wraps at the box width and clips at its height, and the box
3925
+ survives later text / font / size changes)."""
3926
+ if not layer.resize(width, height):
3927
+ raise lib.OpError('gimp_op_failed', 'GIMP could not set the text box to %dx%d' % (width, height))
3928
+
3929
+
3930
+ def _text_is_boxed(layer):
3931
+ """Whether this tool put the layer in a fixed box (`lib.TEXT_BOX_TAG`; GIMP cannot report it)."""
3932
+ return layer.get_parasite(lib.TEXT_BOX_TAG) is not None
3933
+
3934
+
3935
+ def _text_enforce_box(layer):
3936
+ """For a layer tagged as boxed, put it in box mode at its current size again and return True;
3937
+ False for an untagged layer. The tag is what exempts an edit from the pre-render size
3938
+ estimate, and it outlives a switch back to dynamic text (in the GIMP UI, or in a file made
3939
+ elsewhere), so an edit that relies on it re-asserts the box it stands for. One render, at a
3940
+ size the layer already has."""
3941
+ if not _text_is_boxed(layer):
3942
+ return False
3943
+ _text_set_box(layer, layer.get_width(), layer.get_height())
3944
+ return True
3945
+
3946
+
3947
+ def _text_mark_boxed(layer):
3948
+ layer.attach_parasite(Gimp.Parasite.new(lib.TEXT_BOX_TAG, Gimp.PARASITE_PERSISTENT, b'1'))
3949
+
3950
+
3951
+ def _text_layout_height(single, pitch, lines):
3952
+ return single + (lines - 1) * pitch
3953
+
3954
+
3955
+ def _text_wrapped_lines(layer, width, precision, limit_h=None):
3956
+ """How many lines the layer's text wraps to in a box `width` wide, read from a real render:
3957
+ the box is made tall enough that nothing clips and the last inked row gives the line count
3958
+ (GIMP renders text synchronously, so the pixels are current right after the resize). With
3959
+ `limit_h` the box is only a little taller than that limit, enough to tell whether the text
3960
+ fits; a probe cut short by the scan cap, a glyph inked in the last column, or text that
3961
+ renders blank (a glyph wider than the box), reports `lib.TEXT_OVERFLOW_LINES` (the caller
3962
+ probes with left alignment, so only a glyph that does not fit reaches the last column).
3963
+ Leaves the layer's box at the probe height; callers set the final box."""
3964
+ single, pitch = _text_line_metrics(layer)
3965
+ text = layer.get_text()
3966
+ explicit = text.count('\n') + 1
3967
+ cap = lib.text_scan_cap(width, precision)
3968
+ if limit_h is not None:
3969
+ scan_h, clamped = lib.fit_probe_plan(width, limit_h, pitch, precision)
3970
+ else:
3971
+ unwrapped = float(_text_extents(layer.get_font(), _text_size_px(layer), text).width)
3972
+ scan_h = min(cap, int(math.ceil((explicit + 2 * math.ceil(unwrapped / width) + 1) * pitch)))
3973
+ while True:
3974
+ _text_set_box(layer, width, scan_h)
3975
+ bottom, right_ink = _text_ink_extent(layer)
3976
+ if limit_h is not None:
3977
+ blank = bottom < 0 and bool(text.strip()) # GIMP draws nothing when no glyph fits
3978
+ if right_ink or blank or lib.fit_probe_overflows(bottom, scan_h, clamped):
3979
+ return lib.TEXT_OVERFLOW_LINES
3980
+ return lib.wrapped_line_count(bottom, pitch, explicit)
3981
+ # Ink on the probe's last row means it may be clipped: the probe grows until the text
3982
+ # clears it.
3983
+ if bottom < scan_h - 1:
3984
+ return lib.wrapped_line_count(bottom, pitch, explicit)
3985
+ if scan_h >= cap:
3986
+ raise ValueError(
3987
+ 'the text wraps to more than %d px tall at box width %d; use a wider box, a '
3988
+ 'smaller font_size or less text' % (cap, width)
3989
+ )
3990
+ scan_h = min(cap, scan_h * 2)
3991
+
3992
+
3993
+ def _text_grow_box(layer, width, precision):
3994
+ """Set the box to `width` wide and as tall as the wrapped text needs."""
3995
+ lines = _text_wrapped_lines(layer, width, precision)
3996
+ single, pitch = _text_line_metrics(layer)
3997
+ height = max(1, int(math.ceil(_text_layout_height(single, pitch, lines))))
3998
+ lib.check_text_layer_size(width, height)
3999
+ lib.check_text_box_area(width, height, precision)
4000
+ _text_set_box(layer, width, height)
4001
+
4002
+
4003
+ def _text_fit(layer, width, height, max_px, min_px, precision):
4004
+ """Find approximately the largest size in [min_px, max_px] at which the wrapped text fits
4005
+ `width` x `height` (`lib.fit_font_size`, whole pixels, bounded probes) and leave it applied in
4006
+ that box. Returns (fitted, probes).
4007
+
4008
+ Probes run left-aligned: right, centered and filled lines legitimately end at the box's right
4009
+ edge, which would read as overflow, and alignment does not change where lines break."""
4010
+ def fits(size):
4011
+ layer.set_font_size(size, Gimp.Unit.pixel())
4012
+ single, pitch = _text_line_metrics(layer)
4013
+ lines = _text_wrapped_lines(layer, width, precision, limit_h=height)
4014
+ return _text_layout_height(single, pitch, lines) <= height
4015
+
4016
+ justification = layer.get_justification()
4017
+ left = Gimp.TextJustification.LEFT
4018
+ if justification != left:
4019
+ layer.set_justification(left)
4020
+ try:
4021
+ size, fitted, probes = lib.fit_font_size(max_px, min_px, fits)
4022
+ finally:
4023
+ if justification != left:
4024
+ layer.set_justification(justification)
4025
+ layer.set_font_size(size, Gimp.Unit.pixel())
4026
+ _text_set_box(layer, width, height)
4027
+ return fitted, probes
4028
+
4029
+
4030
+ def _text_apply_box(layer, box, fit, min_px, max_px, precision):
4031
+ """Put `layer` in a fixed box (`box` = (width, height or None)); with `fit`, also find the
4032
+ largest font size that fits. Returns the extra result fields."""
4033
+ width, height = box
4034
+ extra = {}
4035
+ if fit:
4036
+ t0 = time.time()
4037
+ fitted, probes = _text_fit(layer, width, height, max_px, min_px, precision)
4038
+ extra = {'fitted': fitted, 'fit_probes': probes, 'fit_seconds': round(time.time() - t0, 3)}
4039
+ elif height is None:
4040
+ _text_grow_box(layer, width, precision)
4041
+ else:
4042
+ lib.check_text_layer_size(width, height)
4043
+ _text_set_box(layer, width, height)
4044
+ return extra
4045
+
4046
+
4047
+ def _text_check_box_request(box, fit, precision):
4048
+ """Refusals that need no render: the box area cap, and fit's probe-size limit."""
4049
+ lib.check_text_box_area(box[0], box[1] or 1, precision)
4050
+ if fit:
4051
+ lib.check_text_fit_box(box[0], box[1], precision)
4052
+
4053
+
4054
+ def _text_box_fields(layer, extra):
4055
+ fields = {'box': {'width': layer.get_width(), 'height': layer.get_height()}}
4056
+ fields.update(extra)
4057
+ return fields
4058
+
4059
+
4060
+ def _op_text_create(img, args):
4061
+ text = lib.validate_text_content(lib.require(args, 'text'))
4062
+ size_pt = lib.validate_font_size_pt(args.get('font_size', lib.TEXT_DEFAULT_FONT_PT))
4063
+ x = int(args.get('x', 100))
4064
+ y = int(args.get('y', 100))
4065
+ _validated_move_offset(img, x, y)
4066
+ rgb = lib.validate_text_rgb(args)
4067
+ box = lib.validate_text_box(args['box']) if args.get('box') is not None else None
4068
+ fit, min_px = lib.validate_text_fit(args, box)
4069
+ justification = None
4070
+ if args.get('alignment') is not None:
4071
+ justification = _JUSTIFICATIONS[lib.justification_nick(args['alignment'])]
4072
+ _layer_type_for(img) # refuses image modes a text layer cannot be added to (indexed)
4073
+ font = _resolved_font(_font_list(), args.get('font_name'))
4074
+ size_px = lib.pt_to_px(size_pt, _image_ppi(img))
4075
+ precision = _transform_layer_precision(img)
4076
+ if box is None:
4077
+ _check_estimated_text_size(text, font, size_px, img)
4078
+ create_px = size_px
4079
+ else:
4080
+ # In a box the layer is bounded by the box, not by the unwrapped text, so there is no
4081
+ # unwrapped-size estimate to take: the layer starts small and is boxed before it is
4082
+ # given its size.
4083
+ lib.check_text_layer_size(box[0], box[1] or 1)
4084
+ _text_check_box_request(box, fit, precision)
4085
+ create_px = min(size_px, _TEXT_PROBE_PX)
4086
+ layer = Gimp.TextLayer.new(img, text, font, create_px, Gimp.Unit.pixel())
4087
+ if layer is None:
4088
+ raise lib.OpError('gimp_op_failed', 'GIMP could not create the text layer')
4089
+ try:
4090
+ img.insert_layer(layer, None, 0)
4091
+ _assert_layer_attached(img, layer, 'the new text layer')
4092
+ # Colour and justification only take effect once the layer is in the image.
4093
+ if rgb is not None:
4094
+ _set_text_color(layer, rgb)
4095
+ if justification is not None:
4096
+ layer.set_justification(justification)
4097
+ extra = {}
4098
+ if box is not None:
4099
+ _text_set_box(layer, box[0], box[1] or max(1, int(math.ceil(create_px))))
4100
+ _text_mark_boxed(layer)
4101
+ layer.set_font_size(size_px, Gimp.Unit.pixel())
4102
+ extra = _text_apply_box(layer, box, fit, min_px, size_px, precision)
4103
+ lib.check_text_layer_size(layer.get_width(), layer.get_height())
4104
+ layer.set_offsets(x, y)
4105
+ record = _text_record(layer)
4106
+ if box is not None:
4107
+ record.update(_text_box_fields(layer, extra))
4108
+ except Exception:
4109
+ _discard_layer(img, layer)
4110
+ raise
4111
+ finally:
4112
+ _drop_proxies(img.get_id())
4113
+ return record
4114
+
4115
+
4116
+ def _text_target(img, args):
4117
+ layer = _layer(img, args)
4118
+ if not layer.is_text_layer():
4119
+ raise ValueError(
4120
+ 'layer %r is not a text layer; address a text layer by layer_id (gimp_inspect '
4121
+ 'what=layers flags them with is_text_layer)' % layer.get_name()
4122
+ )
4123
+ if _has_markup(layer):
4124
+ raise ValueError(
4125
+ 'layer %r has per-character styling (markup), which gimp_text cannot edit without '
4126
+ 'losing it; create a new text layer instead' % layer.get_name()
4127
+ )
4128
+ return layer
4129
+
4130
+
4131
+ def _op_text_edit(img, args, apply_fn, precheck_fn=None):
4132
+ """Run `apply_fn(layer)` on the targeted text layer. `precheck_fn(layer)` runs first, before
4133
+ anything changes, so a refusal there costs no re-render. On any failure in `apply_fn`, or if
4134
+ the re-rendered layer is past the size cap, restore the previous values and re-raise."""
4135
+ layer = _text_target(img, args)
4136
+ if precheck_fn is not None:
4137
+ precheck_fn(layer)
4138
+ state = _text_state(layer)
4139
+ try:
4140
+ apply_fn(layer)
4141
+ lib.check_text_layer_size(layer.get_width(), layer.get_height())
4142
+ except Exception:
4143
+ _text_restore(layer, state)
4144
+ raise
4145
+ finally:
4146
+ _drop_proxies(img.get_id())
4147
+ return _text_record(layer)
4148
+
4149
+
4150
+ def _op_text_set_content(img, args):
4151
+ text = lib.validate_text_content(lib.require(args, 'text'))
4152
+ # A boxed layer is bounded by its box, so there is no unwrapped-size estimate to take.
4153
+ return _op_text_edit(
4154
+ img, args, lambda layer: layer.set_text(text),
4155
+ precheck_fn=lambda layer: None if _text_enforce_box(layer) else _check_estimated_text_size(
4156
+ text, layer.get_font(), _text_size_px(layer), img, layer),
4157
+ )
4158
+
4159
+
4160
+ def _op_text_set_font(img, args):
4161
+ font_name = lib.require(args, 'font_name')
4162
+ size_pt = lib.validate_font_size_pt(args['font_size']) if args.get('font_size') is not None else None
4163
+ font = _resolved_font(_font_list(), font_name)
4164
+ new_size_px = lib.pt_to_px(size_pt, _image_ppi(img)) if size_pt is not None else None
4165
+
4166
+ def precheck_fn(layer):
4167
+ if _text_enforce_box(layer):
4168
+ return
4169
+ size_px = new_size_px if new_size_px is not None else _text_size_px(layer)
4170
+ _check_estimated_text_size(layer.get_text(), font, size_px, img, layer)
4171
+
4172
+ def apply_fn(layer):
4173
+ # Shrinking: size first, so the new font never renders at the larger old size.
4174
+ if new_size_px is not None and new_size_px < _text_size_px(layer):
4175
+ layer.set_font_size(new_size_px, Gimp.Unit.pixel())
4176
+ layer.set_font(font)
4177
+ else:
4178
+ layer.set_font(font)
4179
+ if new_size_px is not None:
4180
+ layer.set_font_size(new_size_px, Gimp.Unit.pixel())
4181
+
4182
+ return _op_text_edit(img, args, apply_fn, precheck_fn=precheck_fn)
4183
+
4184
+
4185
+ def _op_text_set_color(img, args):
4186
+ rgb = lib.validate_text_rgb(args)
4187
+ if rgb is None:
4188
+ raise ValueError('red, green and blue are required')
4189
+ return _op_text_edit(img, args, lambda layer: _set_text_color(layer, rgb))
4190
+
4191
+
4192
+ def _op_text_set_alignment(img, args):
4193
+ justification = _JUSTIFICATIONS[lib.justification_nick(lib.require(args, 'alignment'))]
4194
+ return _op_text_edit(img, args, lambda layer: layer.set_justification(justification))
4195
+
4196
+
4197
+ def _text_trial_layout(layer, box, fit, min_px, size_px, precision):
4198
+ """Dry-run a box (and fit) for a layer GIMP has no way to take back out of box mode: the
4199
+ same text, font, spacing and size on a throwaway layer in a scratch image, so a refusal or
4200
+ failure leaves the real layer untouched. Returns (final size px, width, height, extra)."""
4201
+ font = layer.get_font() or _resolved_font(_font_list(), None)
4202
+ tmp = Gimp.Image.new(1, 1, Gimp.ImageBaseType.RGB)
4203
+ try:
4204
+ trial = Gimp.TextLayer.new(tmp, layer.get_text(), font, _TEXT_PROBE_PX, Gimp.Unit.pixel())
4205
+ if trial is None:
4206
+ raise lib.OpError('gimp_op_failed', 'GIMP could not lay out the text to fit the box')
4207
+ tmp.insert_layer(trial, None, 0)
4208
+ trial.set_justification(layer.get_justification())
4209
+ trial.set_letter_spacing(layer.get_letter_spacing())
4210
+ trial.set_line_spacing(layer.get_line_spacing())
4211
+ trial.set_indent(layer.get_indent())
4212
+ trial.set_kerning(layer.get_kerning())
4213
+ _text_set_box(trial, box[0], max(1, int(math.ceil(_TEXT_PROBE_PX))))
4214
+ trial.set_font_size(size_px, Gimp.Unit.pixel())
4215
+ extra = _text_apply_box(trial, box, fit, min_px, size_px, precision)
4216
+ width, height = trial.get_width(), trial.get_height()
4217
+ lib.check_text_layer_size(width, height)
4218
+ return _text_size_px(trial), width, height, extra
4219
+ finally:
4220
+ tmp.delete()
4221
+
4222
+
4223
+ def _op_text_set_box(img, args):
4224
+ """Put an existing text layer in a fixed box (re-wrapping its text), optionally fitting the
4225
+ size. `font_size` (points) sets the size to start from; default is the layer's current size.
4226
+ A layer not yet boxed is laid out on a throwaway layer first (GIMP cannot take a layer back
4227
+ out of box mode, and changing its size unboxed would re-render the unwrapped text); a boxed
4228
+ one is changed in place and rolled back to its previous box on failure."""
4229
+ box = lib.validate_text_box(lib.require(args, 'box'))
4230
+ fit, min_px = lib.validate_text_fit(args, box)
4231
+ size_pt = lib.validate_font_size_pt(args['font_size']) if args.get('font_size') is not None else None
4232
+ layer = _text_target(img, args)
4233
+ precision = _transform_layer_precision(img)
4234
+ _text_check_box_request(box, fit, precision)
4235
+ start_px = lib.pt_to_px(size_pt, _image_ppi(img)) if size_pt is not None else _text_size_px(layer)
4236
+ state = _text_state(layer)
4237
+ prev_box = (layer.get_width(), layer.get_height())
4238
+ was_boxed = _text_enforce_box(layer)
4239
+ try:
4240
+ if was_boxed:
4241
+ if size_pt is not None:
4242
+ layer.set_font_size(start_px, Gimp.Unit.pixel())
4243
+ extra = _text_apply_box(layer, box, fit, min_px, _text_size_px(layer), precision)
4244
+ else:
4245
+ final_px, width, height, extra = _text_trial_layout(
4246
+ layer, box, fit, min_px, start_px, precision)
4247
+ # Boxed (and so bounded) before its size changes.
4248
+ _text_set_box(layer, box[0], max(1, int(math.ceil(_TEXT_PROBE_PX))))
4249
+ _text_mark_boxed(layer)
4250
+ if fit or size_pt is not None:
4251
+ layer.set_font_size(final_px, Gimp.Unit.pixel())
4252
+ _text_set_box(layer, width, height)
4253
+ lib.check_text_layer_size(layer.get_width(), layer.get_height())
4254
+ except Exception:
4255
+ _text_restore(layer, state)
4256
+ if was_boxed:
4257
+ try:
4258
+ layer.resize(prev_box[0], prev_box[1])
4259
+ except Exception:
4260
+ pass
4261
+ raise
4262
+ finally:
4263
+ _drop_proxies(img.get_id())
4264
+ record = _text_record(layer)
4265
+ record.update(_text_box_fields(layer, extra))
4266
+ return record
4267
+
4268
+
4269
+ TEXT_OPS = {
4270
+ 'create': _op_text_create,
4271
+ 'set_content': _op_text_set_content,
4272
+ 'set_font': _op_text_set_font,
4273
+ 'set_color': _op_text_set_color,
4274
+ 'set_alignment': _op_text_set_alignment,
4275
+ 'set_box': _op_text_set_box,
4276
+ }
4277
+
4278
+
4279
+ def op_text(args):
4280
+ img = _image(args)
4281
+ top = args.get('op')
4282
+ fn = TEXT_OPS.get(top)
4283
+ if fn is None:
4284
+ raise ValueError('op must be one of %s' % sorted(TEXT_OPS))
4285
+ return fn(img, args)
4286
+
4287
+
2623
4288
  # ---- gimp_bake --------------------------------------------------------------------------------
2624
4289
 
2625
4290
 
@@ -2732,8 +4397,9 @@ def op_bake(args):
2732
4397
  # - `Item.scale(width, height, local_origin)` resizes just that one layer; called here while its
2733
4398
  # offset is still whatever `file_load_layer` gave it (verified: local_origin's own effect on
2734
4399
  # 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
4400
+ # `x`/`y` are applied afterward via the same absolute `set_offsets` call every layer-move op
4401
+ # in this bridge uses -- so the requested position is always exact regardless of
4402
+ # local_origin's own math. Both
2737
4403
  # `Drawable.scale()` and `Drawable.fill()` report failure by returning `False` rather than
2738
4404
  # raising (the same class `op_open`'s own `convert_precision` comment documents) -- checked
2739
4405
  # everywhere this file calls either, including the pre-existing `_fill_new_layer` shared with
@@ -2846,8 +4512,13 @@ def op_place_image(args):
2846
4512
  original_selection = img.get_selected_layers()
2847
4513
  baked_filters = []
2848
4514
  try:
4515
+ # The DoS floor applies to the SOURCE file's own size too; checked before any rotation
4516
+ # (it is symmetric in width and height, so the upright size passes or fails alike).
4517
+ lib.validate_resize_dims(loaded.get_width(), loaded.get_height())
4518
+ orientation_applied, _md = _file_orientation(path, ext)
4519
+ if orientation_applied is not None:
4520
+ _upright_placed_layer(loaded, orientation_applied)
2849
4521
  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
4522
 
2852
4523
  width, height = args.get('width'), args.get('height')
2853
4524
  if width is not None or height is not None:
@@ -2912,6 +4583,8 @@ def op_place_image(args):
2912
4583
  }
2913
4584
  if baked_filters:
2914
4585
  result['baked_filters'] = baked_filters
4586
+ if orientation_applied is not None:
4587
+ result['orientation_applied'] = orientation_applied
2915
4588
  return result
2916
4589
 
2917
4590
 
@@ -3045,11 +4718,1178 @@ def op_convert_image_mode(args):
3045
4718
  return {'mode': mode, 'converted': True}
3046
4719
 
3047
4720
 
4721
+ # ---- gimp_transform_layer ---------------------------------------------------------------------
4722
+ #
4723
+ # A per-LAYER affine transform (fit/scale/move/rotate/flip/skew/free) -- distinct from the
4724
+ # whole-CANVAS geometry ops above (crop/resize/rotate/flip), which move or resize every layer
4725
+ # together. Addressed the same way every other gimp_* layer op is (`_layer`): layer_id takes
4726
+ # priority over layer (name), neither given falls back to the selected layer, or the topmost one.
4727
+ #
4728
+ # Verified live (GIMP 3.2.6), the assumptions this section is built on:
4729
+ # - `Item.transform_scale`/`transform_rotate`/`transform_flip_simple`/`transform_matrix` all
4730
+ # carry the layer's own mask along automatically, repositioned and resized to match the
4731
+ # layer's new bounds exactly -- no separate mask transform is needed.
4732
+ # - Calling any of these on a GROUP layer transforms the whole group as a unit: every
4733
+ # descendant's own offsets and size update too, in lockstep with the group's. No special-
4734
+ # casing is needed for a group target beyond its own alpha (every non-group descendant gets
4735
+ # one independently -- a group has no pixels of its own) and its predicted size caps: the
4736
+ # group's own bounding box, each non-group descendant's own predicted size, and the SUM of
4737
+ # those descendant sizes must all fit (GIMP allocates and transforms each descendant's own
4738
+ # pixel buffer independently, so the group's bounding box fitting the cap guarantees
4739
+ # neither a single descendant's buffer nor their combined memory does). A locked layer
4740
+ # anywhere in the group's subtree refuses the whole transform up front.
4741
+ # - `Context.transform_resize` set to ADJUST (rather than the default CLIP) is what makes
4742
+ # `rotate`/`skew`/`free` grow the layer's own bounding box to its new, larger extent instead
4743
+ # of clipping corners that rotate/shear outside the old one -- the same growth
4744
+ # `gimp_transform_canvas`'s `expand` gives the whole canvas, but always-on here since a
4745
+ # layer (unlike the canvas) has no reason to ever clip its own content.
4746
+ # - `Context.interpolation` is read by the transform itself, not passed as an argument to any
4747
+ # of the methods above -- set explicitly before every call (never left at whatever a PRIOR
4748
+ # call happened to leave it at).
4749
+ # - A layer with no alpha channel (e.g. a flattened base layer) would otherwise fill any area
4750
+ # a transform exposes outside its old bounds with an opaque background color rather than
4751
+ # transparency -- `add_alpha()` first avoids that, for every op, not only ones that grow the
4752
+ # bounding box (harmless on one that doesn't need it).
4753
+ # - `set_offsets`/every `transform_*` method reports a lock-position or lock-content layer it
4754
+ # could not transform by returning a FALSY value (`False`/`None`) rather than raising --
4755
+ # checked both proactively (`_refuse_if_layer_locked`, before alpha is ever added) and on the
4756
+ # actual call's own return value (`_checked_transform`, defense in depth).
4757
+ # - A text layer stays a text layer through every op here (verified live for scale, rotate,
4758
+ # flip, skew and free; reported back as `text_layer`), never rasterized.
4759
+ # - Refuses outright when the layer (or a containing group) carries a masked or unverifiable
4760
+ # adjustment filter -- `_refuse_if_masked_filters_on` (the same scoped check the whole-image
4761
+ # geometry ops' own `_refuse_if_masked_filters` is a sibling of). An UNMASKED adjustment
4762
+ # filter, or an unmasked position/direction-dependent EFFECT filter (vignette, motion_blur,
4763
+ # drop_shadow) under flip, an exact 90-degree-multiple rotate, or a uniform scale, is instead
4764
+ # REMAPPED the same way the canvas geometry ops remap it (`_snapshot_effect_transform`,
4765
+ # scoped to this layer's own subtree); an arbitrary-angle rotate, any skew or free transform,
4766
+ # or a non-uniform scale is refused outright while such an effect is present, the same way
4767
+ # the canvas ops refuse an arbitrary-angle rotate.
4768
+
4769
+
4770
+ def _transform_layer_center(layer):
4771
+ ok, x, y = layer.get_offsets()
4772
+ return (x if ok else 0) + layer.get_width() / 2.0, (y if ok else 0) + layer.get_height() / 2.0
4773
+
4774
+
4775
+ def _transform_layer_bounds(layer):
4776
+ ok, x, y = layer.get_offsets()
4777
+ return {
4778
+ 'x': x if ok else None, 'y': y if ok else None,
4779
+ 'width': layer.get_width(), 'height': layer.get_height(),
4780
+ }
4781
+
4782
+
4783
+ def _set_transform_layer_interpolation(args):
4784
+ choice = lib.validate_choice(
4785
+ 'interpolation', args.get('interpolation', 'cubic'), lib.TRANSFORM_LAYER_INTERPOLATIONS
4786
+ )
4787
+ Gimp.context_set_interpolation(_INTERPOLATION_ENUM[choice])
4788
+ return choice
4789
+
4790
+
4791
+ def _snap_px(value):
4792
+ """Round-half-up to the nearest whole pixel -- `floor(v + 0.5)`, not Python's own banker's
4793
+ `round()` (which rounds a .5 to the nearest EVEN integer, silently snapping e.g. 2.5 down to
4794
+ 2). Every target rectangle `fit`/`scale` computes, and every `move` target, snaps to an
4795
+ integer pixel this same, predictable way, so a layer already exactly sized/positioned for the
4796
+ request is a true no-op -- never a half-pixel resample from float drift alone."""
4797
+ return int(math.floor(value + 0.5))
4798
+
4799
+
4800
+ def _refuse_if_layer_locked(layer):
4801
+ """Refuses outright, before anything else runs (including adding alpha), when `layer` -- or,
4802
+ for a group, any layer in its subtree that the transform would move -- is lock-position or
4803
+ lock-content. Verified live (GIMP 3.2.6): GIMP does not raise for either -- it silently
4804
+ no-ops `set_offsets`/every `transform_*` method instead (`_checked_transform`'s own comment)
4805
+ -- so this catches it earlier, naming the offending layer, rather than leave a group
4806
+ half-transformed or fall through to that generic defense-in-depth check alone."""
4807
+ for item in _layer_subtree(layer):
4808
+ if item.get_lock_position():
4809
+ raise ValueError(
4810
+ 'layer %r is lock-position and cannot be transformed; unlock it first'
4811
+ % item.get_name()
4812
+ )
4813
+ if item.get_lock_content():
4814
+ raise ValueError(
4815
+ 'layer %r is lock-content and cannot be transformed; unlock it first'
4816
+ % item.get_name()
4817
+ )
4818
+
4819
+
4820
+ def _checked_transform(layer, result):
4821
+ """Verifies a `Item.transform_*`/`set_offsets` call's own return value actually did what it
4822
+ claims. GIMP reports a layer it could not transform (lock-position, lock-content, or any
4823
+ other reason `_refuse_if_layer_locked`'s own proactive check didn't already catch) by
4824
+ returning a FALSY value instead of raising -- `False` for `set_offsets`, `None` for every
4825
+ `transform_*` method (verified live, GIMP 3.2.6) -- so a caller that trusts the call
4826
+ unconditionally would silently report success (and, worse, alpha_added: true) for a
4827
+ transform that never actually happened. `result is True` (`set_offsets`' own success value)
4828
+ is accepted without an id to compare; anything else truthy must be the SAME item this call
4829
+ targeted, not some other one GIMP happened to return."""
4830
+ if not result:
4831
+ raise lib.OpError(
4832
+ 'gimp_op_failed',
4833
+ 'GIMP did not transform layer %r -- it may be lock-position or lock-content'
4834
+ % layer.get_name()
4835
+ )
4836
+ if result is not True and result.get_id() != layer.get_id():
4837
+ raise lib.OpError(
4838
+ 'gimp_op_failed', 'GIMP transformed a different item than layer %r' % layer.get_name()
4839
+ )
4840
+
4841
+
4842
+ def _ensure_layer_alpha(layer):
4843
+ """Adds an alpha channel to `layer` if it doesn't have one already. For a GROUP target, adds
4844
+ one to every NON-GROUP descendant that lacks it instead (a group has no pixels of its own to
4845
+ add a channel to, but transforming it cascades to every descendant, each of which can expose
4846
+ a new area the same way a plain layer target can). Returns whether alpha was actually added
4847
+ anywhere."""
4848
+ if layer.is_group():
4849
+ added = False
4850
+ for descendant in _layer_subtree(layer):
4851
+ if descendant.get_id() == layer.get_id() or descendant.is_group():
4852
+ continue
4853
+ if descendant.has_alpha():
4854
+ continue
4855
+ if not descendant.add_alpha():
4856
+ raise lib.OpError(
4857
+ 'gimp_op_failed',
4858
+ 'GIMP could not add an alpha channel to layer %r' % descendant.get_name()
4859
+ )
4860
+ added = True
4861
+ return added
4862
+ if layer.has_alpha():
4863
+ return False
4864
+ if not layer.add_alpha():
4865
+ raise lib.OpError('gimp_op_failed', 'GIMP could not add an alpha channel to this layer')
4866
+ return True
4867
+
4868
+
4869
+ def _tracked_effect_names_on(img, layer, rotation_only=False):
4870
+ """Live, ledgered position/direction-dependent EFFECT filter names (vignette, motion_blur,
4871
+ drop_shadow -- `ROTATE_DEPENDENT_OPERATIONS`) anywhere in `layer`'s own subtree (itself, or
4872
+ every descendant for a group target) -- NOT its ancestors: an ancestor group's own filter is
4873
+ defined relative to the ANCESTOR's own extent, which transforming a descendant inside it
4874
+ never changes, so it needs neither remapping nor refusing here (unlike a MASKED adjustment
4875
+ filter's mask-channel alignment, which does -- see `_refuse_if_masked_filters_on`)."""
4876
+ subtree = _layer_subtree(layer)
4877
+ live_names = {f.get_name() for l in subtree for f in l.get_filters()}
4878
+ filters, _unknown = _ledger_get(img)
4879
+ if rotation_only: # an offset-free dropshadow (outline, outer_glow) survives any angle
4880
+ return sorted(name for name in live_names if _rotation_sensitive(filters.get(name, {})))
4881
+ return sorted(
4882
+ name for name in live_names
4883
+ if filters.get(name, {}).get('operation') in ROTATE_DEPENDENT_OPERATIONS
4884
+ )
4885
+
4886
+
4887
+ def _refuse_if_tracked_effects_on(img, op_name, layer, reason):
4888
+ tracked = _tracked_effect_names_on(img, layer)
4889
+ if tracked:
4890
+ raise ValueError(
4891
+ '%s cannot proceed while effect filter(s) %s are present on this layer (or a group '
4892
+ 'containing it): %s. Delete these filters first and re-add them afterwards.'
4893
+ % (op_name, ', '.join(repr(n) for n in tracked), reason)
4894
+ )
4895
+
4896
+
4897
+ def _refuse_if_non_right_angle_tracked_effects_on(img, degrees, op_name, layer):
4898
+ if lib.is_right_angle_degrees(degrees):
4899
+ return
4900
+ tracked = _tracked_effect_names_on(img, layer, rotation_only=True)
4901
+ if tracked:
4902
+ raise ValueError(
4903
+ '%s cannot use an arbitrary angle (%.4g°) while effect filter(s) %s are present on '
4904
+ 'this layer (or a group containing it): only an exact 0/90/180/270-degree rotation '
4905
+ 'keeps them locked to the content. Rotate at a right angle instead, or delete these '
4906
+ 'filters first and re-add them afterwards.'
4907
+ % (op_name, degrees, ', '.join(repr(n) for n in tracked))
4908
+ )
4909
+
4910
+
4911
+ def _transform_layer_precision(img):
4912
+ return lib.transform_layer_precision_bucket(img.get_precision().value_nick)
4913
+
4914
+
4915
+ def _validate_transform_sizes(img, layer, matrix, precision):
4916
+ """Validates `layer`'s own PREDICTED size (via `lib.transformed_bounds`, mapping the
4917
+ layer's current rect through `matrix`) against the engine's precision-aware size cap
4918
+ (`lib.validate_document_dims`, the same one gimp_create_document uses) -- and, for a GROUP
4919
+ target, every non-group descendant's own predicted size the same way, plus the descendants'
4920
+ 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
4921
+ is exactly `transformed_bounds`' own (new_x, new_y) call `_validated_move_offset` on it
4922
+ themselves (`_validate_transform_prediction`, below); `fit`'s own target is the canvas
4923
+ center instead, validated separately. Returns the layer's own predicted
4924
+ (new_x, new_y, new_w, new_h)."""
4925
+ ok, ox, oy = layer.get_offsets()
4926
+ ox, oy = (ox if ok else 0), (oy if ok else 0)
4927
+ w, h = layer.get_width(), layer.get_height()
4928
+ new_x, new_y, new_w, new_h = lib.transformed_bounds(matrix, ox, oy, w, h)
4929
+ lib.validate_document_dims(
4930
+ lib.ceil_with_margin(new_w), lib.ceil_with_margin(new_h), precision=precision
4931
+ )
4932
+ if layer.is_group():
4933
+ sizes = []
4934
+ for descendant in _layer_subtree(layer):
4935
+ if descendant.get_id() == layer.get_id() or descendant.is_group():
4936
+ continue
4937
+ dok, dx, dy = descendant.get_offsets()
4938
+ dx, dy = (dx if dok else 0), (dy if dok else 0)
4939
+ dw, dh = descendant.get_width(), descendant.get_height()
4940
+ _, _, pred_w, pred_h = lib.transformed_bounds(matrix, dx, dy, dw, dh)
4941
+ pw, ph = lib.ceil_with_margin(pred_w), lib.ceil_with_margin(pred_h)
4942
+ lib.validate_document_dims(pw, ph, precision=precision)
4943
+ sizes.append((pw, ph))
4944
+ lib.validate_group_total_pixels(sizes, precision)
4945
+ return new_x, new_y, new_w, new_h
4946
+
4947
+
4948
+ def _validate_transform_prediction(img, layer, matrix, precision):
4949
+ """`_validate_transform_sizes` plus the same predicted-ORIGIN bound `move` enforces
4950
+ (`_validated_move_offset`) -- every op whose own target position IS `transformed_bounds`'
4951
+ predicted (new_x, new_y): rotate, skew, free, and scale (`fit`'s own target is the canvas
4952
+ center instead; see its own handler)."""
4953
+ new_x, new_y, new_w, new_h = _validate_transform_sizes(img, layer, matrix, precision)
4954
+ _validated_move_offset(img, new_x, new_y)
4955
+ return new_x, new_y, new_w, new_h
4956
+
4957
+
4958
+ def _transform_layer_fit(layer, img, args):
4959
+ mode = lib.validate_choice('mode', args.get('mode', 'fit'), ('fit', 'fill'))
4960
+ w, h = layer.get_width(), layer.get_height()
4961
+ fraction = lib.fit_scale_fraction(w, h, img.get_width(), img.get_height(), mode)
4962
+ if abs(fraction - 1.0) > 1e-9:
4963
+ _refuse_if_tracked_effects_on(
4964
+ img, 'fit', layer, 'fit/fill cannot keep them locked to the content at this scale'
4965
+ )
4966
+ cx, cy = _transform_layer_center(layer)
4967
+ matrix = lib.compose_layer_matrix(cx, cy, fraction * 100.0, fraction * 100.0, 0.0, 0.0, 0.0, 0.0, 0.0)
4968
+ precision = _transform_layer_precision(img)
4969
+ # fit's own target is centered on the CANVAS, not wherever a scale-about-its-own-center
4970
+ # would leave it -- only `matrix`'s predicted SIZE is used below; its predicted origin is
4971
+ # irrelevant and is never read.
4972
+ _, _, new_w, new_h = _validate_transform_sizes(img, layer, matrix, precision)
4973
+ new_w_i, new_h_i = _snap_px(new_w), _snap_px(new_h)
4974
+ lib.validate_transformed_layer_size(new_w_i, new_h_i)
4975
+ target_x = (img.get_width() - new_w_i) / 2.0
4976
+ target_y = (img.get_height() - new_h_i) / 2.0
4977
+ _validated_move_offset(img, target_x, target_y)
4978
+ planned = _snapshot_effect_transform(
4979
+ img, 'fit',
4980
+ lambda operation, params, flayer: lib.resize_effect_params(operation, params, fraction, fraction),
4981
+ layers=_layer_subtree(layer),
4982
+ )
4983
+ alpha_added = _ensure_layer_alpha(layer)
4984
+ x0, y0 = _snap_px(target_x), _snap_px(target_y)
4985
+ result = layer.transform_scale(float(x0), float(y0), float(x0 + new_w_i), float(y0 + new_h_i))
4986
+ _checked_transform(layer, result)
4987
+ failures = _apply_planned_effect_transform(img, planned)
4988
+ extra = {'mode': mode, 'scale_percent': round(fraction * 100.0, 6), 'alpha_added': alpha_added}
4989
+ if failures:
4990
+ extra['effect_update_failures'] = failures
4991
+ return extra
4992
+
4993
+
4994
+ def _transform_layer_scale(layer, img, args):
4995
+ lo, hi = lib.TRANSFORM_LAYER_MIN_SCALE_PERCENT, lib.TRANSFORM_LAYER_MAX_SCALE_PERCENT
4996
+ sp = args.get('scale_percent')
4997
+ sx_in, sy_in = args.get('scale_x_percent'), args.get('scale_y_percent')
4998
+ if sx_in is None and sy_in is None:
4999
+ if sp is None:
5000
+ raise ValueError('scale requires scale_percent, or scale_x_percent/scale_y_percent')
5001
+ sx = sy = lib.validate_range('scale_percent', sp, lo, hi)
5002
+ else:
5003
+ 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)
5004
+ 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)
5005
+ if abs(sx - sy) > 1e-9:
5006
+ _refuse_if_tracked_effects_on(
5007
+ img, 'scale', layer, 'a non-uniform scale cannot keep them locked to the content'
5008
+ )
5009
+ cx, cy = _transform_layer_center(layer)
5010
+ matrix = lib.compose_layer_matrix(cx, cy, sx, sy, 0.0, 0.0, 0.0, 0.0, 0.0)
5011
+ precision = _transform_layer_precision(img)
5012
+ new_x, new_y, new_w, new_h = _validate_transform_prediction(img, layer, matrix, precision)
5013
+ planned = _snapshot_effect_transform(
5014
+ img, 'scale',
5015
+ lambda operation, params, flayer: lib.resize_effect_params(operation, params, sx / 100.0, sy / 100.0),
5016
+ layers=_layer_subtree(layer),
5017
+ )
5018
+ alpha_added = _ensure_layer_alpha(layer)
5019
+ x0, y0 = _snap_px(new_x), _snap_px(new_y)
5020
+ x1, y1 = _snap_px(new_x + new_w), _snap_px(new_y + new_h)
5021
+ lib.validate_transformed_layer_size(x1 - x0, y1 - y0)
5022
+ result = layer.transform_scale(float(x0), float(y0), float(x1), float(y1))
5023
+ _checked_transform(layer, result)
5024
+ failures = _apply_planned_effect_transform(img, planned)
5025
+ extra = {'scale_x_percent': sx, 'scale_y_percent': sy, 'alpha_added': alpha_added}
5026
+ if failures:
5027
+ extra['effect_update_failures'] = failures
5028
+ return extra
5029
+
5030
+
5031
+ def _transform_layer_move(layer, img, args):
5032
+ delta, absolute, center_on = args.get('delta'), args.get('absolute'), args.get('center_on')
5033
+ given = [m for m in (delta, absolute, center_on) if m is not None]
5034
+ if len(given) == 0:
5035
+ raise ValueError(
5036
+ 'move requires exactly one of delta, absolute, center_on -- each a {x, y} object; '
5037
+ 'flat fields like delta_x/absolute_x are not accepted'
5038
+ )
5039
+ if len(given) > 1:
5040
+ raise ValueError(
5041
+ 'move accepts only ONE of delta, absolute, center_on -- not more than one (each a '
5042
+ '{x, y} object; flat fields like delta_x/absolute_x are not accepted)'
5043
+ )
5044
+ ok, ox, oy = layer.get_offsets()
5045
+ ox, oy = (ox if ok else 0), (oy if ok else 0)
5046
+ w, h = layer.get_width(), layer.get_height()
5047
+ if delta is not None:
5048
+ nx, ny = ox + float(lib.require(delta, 'x')), oy + float(lib.require(delta, 'y'))
5049
+ elif absolute is not None:
5050
+ nx, ny = float(lib.require(absolute, 'x')), float(lib.require(absolute, 'y'))
5051
+ else:
5052
+ nx = float(lib.require(center_on, 'x')) - w / 2.0
5053
+ ny = float(lib.require(center_on, 'y')) - h / 2.0
5054
+ _validated_move_offset(img, nx, ny)
5055
+ alpha_added = _ensure_layer_alpha(layer)
5056
+ result = layer.set_offsets(_snap_px(nx), _snap_px(ny))
5057
+ _checked_transform(layer, result)
5058
+ return {'alpha_added': alpha_added}
5059
+
5060
+
5061
+ def _transform_layer_rotate(layer, img, args):
5062
+ degrees = float(lib.require(args, 'degrees'))
5063
+ if not math.isfinite(degrees):
5064
+ raise ValueError('degrees must be finite')
5065
+ _refuse_if_non_right_angle_tracked_effects_on(img, degrees, 'rotate', layer)
5066
+ cx, cy = _transform_layer_center(layer)
5067
+ matrix = lib.compose_layer_matrix(cx, cy, 100.0, 100.0, degrees, 0.0, 0.0, 0.0, 0.0)
5068
+ precision = _transform_layer_precision(img)
5069
+ _validate_transform_prediction(img, layer, matrix, precision)
5070
+ planned = _snapshot_effect_transform(
5071
+ img, 'rotate',
5072
+ lambda operation, params, flayer: lib.rotate_effect_params(
5073
+ operation, params, degrees, flayer.get_width(), flayer.get_height()
5074
+ ),
5075
+ layers=_layer_subtree(layer),
5076
+ )
5077
+ alpha_added = _ensure_layer_alpha(layer)
5078
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
5079
+ result = layer.transform_rotate(math.radians(degrees), True, 0.0, 0.0)
5080
+ _checked_transform(layer, result)
5081
+ failures = _apply_planned_effect_transform(img, planned)
5082
+ extra = {'degrees': degrees, 'alpha_added': alpha_added}
5083
+ if failures:
5084
+ extra['effect_update_failures'] = failures
5085
+ return extra
5086
+
5087
+
5088
+ def _transform_layer_flip(layer, img, args):
5089
+ axis = args.get('axis')
5090
+ if axis not in _FLIP_ORIENTATIONS:
5091
+ raise ValueError('axis must be one of %s' % sorted(_FLIP_ORIENTATIONS))
5092
+ planned = _snapshot_effect_transform(
5093
+ img, 'flip',
5094
+ lambda operation, params, flayer: lib.flip_effect_params(operation, params, axis),
5095
+ layers=_layer_subtree(layer),
5096
+ )
5097
+ alpha_added = _ensure_layer_alpha(layer)
5098
+ result = layer.transform_flip_simple(_FLIP_ORIENTATIONS[axis], True, 0.0)
5099
+ _checked_transform(layer, result)
5100
+ failures = _apply_planned_effect_transform(img, planned)
5101
+ extra = {'axis': axis, 'alpha_added': alpha_added}
5102
+ if failures:
5103
+ extra['effect_update_failures'] = failures
5104
+ return extra
5105
+
5106
+
5107
+ def _transform_layer_skew(layer, img, args):
5108
+ skew_h, skew_v = args.get('skew_h_degrees'), args.get('skew_v_degrees')
5109
+ if skew_h is None and skew_v is None:
5110
+ raise ValueError('skew requires at least one of skew_h_degrees, skew_v_degrees')
5111
+ skew_h = float(skew_h) if skew_h is not None else 0.0
5112
+ skew_v = float(skew_v) if skew_v is not None else 0.0
5113
+ if not (math.isfinite(skew_h) and math.isfinite(skew_v)):
5114
+ raise ValueError('skew_h_degrees/skew_v_degrees must be finite')
5115
+ _refuse_if_tracked_effects_on(
5116
+ img, 'skew', layer, 'a skew cannot keep them locked to the content at any angle'
5117
+ )
5118
+ cx, cy = _transform_layer_center(layer)
5119
+ matrix = lib.compose_layer_matrix(cx, cy, 100.0, 100.0, 0.0, skew_h, skew_v, 0.0, 0.0)
5120
+ precision = _transform_layer_precision(img)
5121
+ _validate_transform_prediction(img, layer, matrix, precision)
5122
+ alpha_added = _ensure_layer_alpha(layer)
5123
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
5124
+ result = layer.transform_matrix(*matrix)
5125
+ _checked_transform(layer, result)
5126
+ return {'skew_h_degrees': skew_h, 'skew_v_degrees': skew_v, 'alpha_added': alpha_added}
5127
+
5128
+
5129
+ def _transform_layer_free(layer, img, args):
5130
+ lo, hi = lib.TRANSFORM_LAYER_MIN_SCALE_PERCENT, lib.TRANSFORM_LAYER_MAX_SCALE_PERCENT
5131
+ sx = lib.validate_range('scale_x_percent', args.get('scale_x_percent', 100.0), lo, hi)
5132
+ sy = lib.validate_range('scale_y_percent', args.get('scale_y_percent', 100.0), lo, hi)
5133
+ degrees = float(args.get('degrees', 0.0))
5134
+ off_lo, off_hi = -lib.TRANSFORM_LAYER_MAX_OFFSET_PX, lib.TRANSFORM_LAYER_MAX_OFFSET_PX
5135
+ offset_x = lib.validate_range('offset_x', args.get('offset_x', 0.0), off_lo, off_hi)
5136
+ offset_y = lib.validate_range('offset_y', args.get('offset_y', 0.0), off_lo, off_hi)
5137
+ if not math.isfinite(degrees):
5138
+ raise ValueError('degrees must be finite')
5139
+ _refuse_if_tracked_effects_on(
5140
+ img, 'free', layer, 'a combined free transform cannot keep them locked to the content'
5141
+ )
5142
+ cx, cy = _transform_layer_center(layer)
5143
+ matrix = lib.compose_layer_matrix(cx, cy, sx, sy, degrees, 0.0, 0.0, offset_x, offset_y)
5144
+ precision = _transform_layer_precision(img)
5145
+ _validate_transform_prediction(img, layer, matrix, precision)
5146
+ alpha_added = _ensure_layer_alpha(layer)
5147
+ Gimp.context_set_transform_resize(Gimp.TransformResize.ADJUST)
5148
+ result = layer.transform_matrix(*matrix)
5149
+ _checked_transform(layer, result)
5150
+ return {
5151
+ 'scale_x_percent': sx, 'scale_y_percent': sy, 'degrees': degrees,
5152
+ 'offset_x': offset_x, 'offset_y': offset_y, 'alpha_added': alpha_added,
5153
+ }
5154
+
5155
+
5156
+ TRANSFORM_LAYER_OPS = {
5157
+ 'fit': _transform_layer_fit,
5158
+ 'scale': _transform_layer_scale,
5159
+ 'move': _transform_layer_move,
5160
+ 'rotate': _transform_layer_rotate,
5161
+ 'flip': _transform_layer_flip,
5162
+ 'skew': _transform_layer_skew,
5163
+ 'free': _transform_layer_free,
5164
+ }
5165
+
5166
+
5167
+ def op_transform_layer(args):
5168
+ img = _image(args)
5169
+ top = args.get('op')
5170
+ fn = TRANSFORM_LAYER_OPS.get(top)
5171
+ if fn is None:
5172
+ raise ValueError('op must be one of %s' % sorted(TRANSFORM_LAYER_OPS))
5173
+ lib.reject_foreign_transform_fields(top, args)
5174
+ layer = _layer(img, args)
5175
+ _refuse_if_masked_filters_on(img, top, layer)
5176
+ _refuse_if_layer_locked(layer)
5177
+ # Interpolation (and, for rotate/skew/free, the ADJUST transform-resize mode) are ambient
5178
+ # Context settings the transform methods below read rather than take as arguments -- pushed
5179
+ # and popped so this call's choice never leaks into a LATER, unrelated gimp_* call (the same
5180
+ # bracket `_fill_new_layer` uses around its own context changes). Each op validates its own
5181
+ # arguments, the predicted result, and any tracked effect in scope, and raises BEFORE calling
5182
+ # `_ensure_layer_alpha` (own code) or mutating anything, so a rejected call never adds an
5183
+ # alpha channel it then goes on to refuse.
5184
+ Gimp.context_push()
5185
+ try:
5186
+ interpolation = _set_transform_layer_interpolation(args)
5187
+ Gimp.Selection.none(img) # see op_open: geometry always applies to the whole layer
5188
+ extra = fn(layer, img, args)
5189
+ finally:
5190
+ Gimp.context_pop()
5191
+ _drop_proxies(img.get_id())
5192
+ result = {
5193
+ 'layer_id': layer.get_id(),
5194
+ 'bounds': _transform_layer_bounds(layer),
5195
+ 'interpolation': interpolation,
5196
+ 'text_layer': layer.is_text_layer(),
5197
+ }
5198
+ result.update(extra)
5199
+ return result
5200
+
5201
+
3048
5202
  def op_select_none(args):
3049
5203
  Gimp.Selection.none(_image(args))
3050
5204
  return {'selection': 'none'}
3051
5205
 
3052
5206
 
5207
+ # ---------- selection: gimp_select / gimp_modify_selection / gimp_layer_mask / masks ------------
5208
+
5209
+ _CHANNEL_OPS = {
5210
+ 'replace': Gimp.ChannelOps.REPLACE, 'add': Gimp.ChannelOps.ADD,
5211
+ 'subtract': Gimp.ChannelOps.SUBTRACT, 'intersect': Gimp.ChannelOps.INTERSECT,
5212
+ }
5213
+
5214
+
5215
+ def _selection_to_channel(img, name):
5216
+ """Copy the active selection into a (replaced) named channel, clear the selection, and return
5217
+ the op result every selection op reports."""
5218
+ w, h = img.get_width(), img.get_height()
5219
+ rect = Gegl.Rectangle.new(0, 0, w, h)
5220
+ data = img.get_selection().get_buffer().get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
5221
+ ch = _replace_named_channel(img, name, w, h)
5222
+ buf = ch.get_buffer()
5223
+ buf.set(rect, "Y' u8", data)
5224
+ buf.flush()
5225
+ ch.update(0, 0, w, h)
5226
+ Gimp.Selection.none(img)
5227
+ _drop_proxies(img.get_id())
5228
+ selected, fraction = _channel_coverage(ch, w, h)
5229
+ return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
5230
+
5231
+
5232
+ def _with_scratch_channel(img, w, h, fn):
5233
+ """Run `fn(scratch_channel)` against a throwaway channel inserted for the duration of the
5234
+ call, then remove it regardless of outcome -- including when `fn` itself raises partway
5235
+ through (e.g. a bad gradient argument), so a failed select never leaves a stray channel
5236
+ behind. Used to stash a selection's pixel data somewhere durable (a GIMP selection is a
5237
+ single, unnamed piece of image state -- building a SECOND shape while the first is still
5238
+ needed, as combine != 'replace' does, has nowhere else to put it).
5239
+
5240
+ Verified the same defensive way `_assert_layer_attached` checks a new layer: some GIMP calls
5241
+ report success having silently done nothing, so insertion is confirmed by walking the image's
5242
+ own live channel list rather than trusting the call's return value alone. If that check fails,
5243
+ there is nothing to remove (the channel was never actually attached). Restores the image's
5244
+ selected layers afterward -- see `_replace_named_channel`'s identical comment for why."""
5245
+ original_selection = img.get_selected_layers()
5246
+ scratch = Gimp.Channel.new(img, '__select_scratch__', w, h, 50.0, Gegl.Color.new('black'))
5247
+ img.insert_channel(scratch, None, 0)
5248
+ if not any(ch.get_id() == scratch.get_id() for ch in img.get_channels()):
5249
+ raise lib.OpError('gimp_op_failed', 'the scratch channel did not attach to the image')
5250
+ img.set_selected_layers(original_selection)
5251
+ try:
5252
+ return fn(scratch)
5253
+ finally:
5254
+ img.remove_channel(scratch)
5255
+ img.set_selected_layers(original_selection)
5256
+
5257
+
5258
+ def _capture_selection(img, w, h, scratch):
5259
+ """Copy the CURRENT active selection's pixels into `scratch`'s own buffer -- a durable
5260
+ snapshot the caller can reload as the active selection later, after building a SECOND shape
5261
+ (`_shape_selection`'s next call) would otherwise overwrite the first one."""
5262
+ data = img.get_selection().get_buffer().get(
5263
+ Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
5264
+ buf = scratch.get_buffer()
5265
+ buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", data)
5266
+ buf.flush()
5267
+ scratch.update(0, 0, w, h)
5268
+
5269
+
5270
+ def _harden_selection(img):
5271
+ """Re-threshold the active selection at 50%: what GIMP's `Selection.sharpen` does, done on the
5272
+ mask's own bytes instead. On macOS GIMP 3.2.6 `Selection.sharpen` leaves the selection empty
5273
+ (a fully selected pixel reads 0 afterwards), while reading and writing the mask buffer works
5274
+ on every platform. Banded, so a very large mask is never held twice in memory."""
5275
+ if Gimp.Selection.is_empty(img):
5276
+ return
5277
+ w, h = img.get_width(), img.get_height()
5278
+ source = img.get_selection().get_buffer()
5279
+
5280
+ def _fill(scratch):
5281
+ out = scratch.get_buffer()
5282
+ for y in range(0, h, lib.HARDEN_BAND_ROWS):
5283
+ rect = Gegl.Rectangle.new(0, y, w, min(lib.HARDEN_BAND_ROWS, h - y))
5284
+ band = source.get(rect, 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
5285
+ out.set(rect, "Y' u8", lib.harden_mask_bytes(band))
5286
+ out.flush()
5287
+ scratch.update(0, 0, w, h)
5288
+ img.select_item(Gimp.ChannelOps.REPLACE, scratch)
5289
+
5290
+ _with_scratch_channel(img, w, h, _fill)
5291
+
5292
+
5293
+ def _shrink_edge_locked(img, px):
5294
+ """`Gimp.Selection.shrink` that does not erode from the canvas edge. GIMP's own shrink treats
5295
+ the area outside the canvas as unselected (its PDB call hardcodes edge-lock off), so a
5296
+ selection touching the edge lost a strip there. Shrinking is the inverse of growing the
5297
+ inverse, and a grow never reaches past the canvas, so shrink = invert, grow, invert."""
5298
+ Gimp.Selection.invert(img)
5299
+ Gimp.Selection.grow(img, px)
5300
+ Gimp.Selection.invert(img)
5301
+
5302
+
5303
+ def _color_arg(img, drawable, args, sample_merged):
5304
+ """The target color for mode=color_range: an explicit `color` hex, or the color AT a sample
5305
+ point (x, y), always in document pixels. `Gimp.Image.pick_color` takes document coordinates
5306
+ regardless of `sample_merged` and itself reports failure -- rather than a wrong or default
5307
+ pixel -- for a point outside the image, or (when not sampling the composite) outside the one
5308
+ given drawable's own bounds (verified live, GIMP 3.2.6: an out-of-bounds point returns
5309
+ `(False, None)`, never raises and never silently samples the wrong pixel)."""
5310
+ color = args.get('color')
5311
+ if color is not None:
5312
+ return Gegl.Color.new(lib.validate_hex_color('color', color))
5313
+ if args.get('x') is None or args.get('y') is None:
5314
+ raise ValueError('color_range needs `color` (e.g. "#c0392b") or a sample point x, y')
5315
+ x, y = float(args['x']), float(args['y'])
5316
+ success, picked = img.pick_color([drawable], x, y, sample_merged, False, 0.0)
5317
+ if not success or picked is None:
5318
+ where = 'the image' if sample_merged else 'layer %r' % drawable.get_name()
5319
+ raise ValueError('sample point (%g, %g) is outside %s' % (x, y, where))
5320
+ return picked
5321
+
5322
+
5323
+ def _layer_local_point(drawable, x, y):
5324
+ """Convert a DOCUMENT-space point to coordinates local to `drawable`, refusing one outside its
5325
+ bounds. `Gimp.Image.select_contiguous_color` (unlike `pick_color`) takes LOCAL coordinates when
5326
+ sampling one drawable rather than the composite -- verified live, GIMP 3.2.6: an unconverted
5327
+ document-space point on an offset layer selected the wrong region of that layer entirely
5328
+ (or nothing, if the document point happened to fall outside the layer's own pixel extent)."""
5329
+ _ok, ox, oy = drawable.get_offsets()
5330
+ lx, ly = x - ox, y - oy
5331
+ lw, lh = drawable.get_width(), drawable.get_height()
5332
+ if not (0 <= lx < lw and 0 <= ly < lh):
5333
+ raise ValueError(
5334
+ 'sample point (%g, %g) is outside layer %r (%dx%d at offset %d,%d)'
5335
+ % (x, y, drawable.get_name(), lw, lh, ox, oy)
5336
+ )
5337
+ return lx, ly
5338
+
5339
+
5340
+ # A DoS floor on mode=polygon's point count -- the same reasoning as the resize/document pixel
5341
+ # caps elsewhere in this file.
5342
+ MAX_POLYGON_POINTS = 10_000
5343
+
5344
+
5345
+ def _polygon_segments(points):
5346
+ """Validate `points` (mode=polygon's own argument) and flatten it into GIMP's flat
5347
+ [x1, y1, x2, y2, ...] segment list. Every point must be an [x, y] pair of finite numbers -- a
5348
+ malformed one (not a pair, a non-numeric value, NaN/Infinity) is refused here rather than
5349
+ reaching `Gimp.Image.select_polygon` as garbage or silently coercing to 0."""
5350
+ if not isinstance(points, list) or len(points) < 3:
5351
+ raise ValueError('points must be a list of at least 3 [x, y] pairs')
5352
+ if len(points) > MAX_POLYGON_POINTS:
5353
+ raise ValueError('points must be at most %d pairs' % MAX_POLYGON_POINTS)
5354
+ segs = []
5355
+ for i, p in enumerate(points):
5356
+ if not isinstance(p, (list, tuple)) or len(p) != 2:
5357
+ raise ValueError('points[%d] must be an [x, y] pair' % i)
5358
+ x, y = p
5359
+ if (
5360
+ not isinstance(x, (int, float)) or not isinstance(y, (int, float))
5361
+ or not (math.isfinite(x) and math.isfinite(y))
5362
+ ):
5363
+ raise ValueError('points[%d] must be finite numbers' % i)
5364
+ segs.extend([float(x), float(y)])
5365
+ return segs
5366
+
5367
+
5368
+ def _shape_selection(img, mode, args, w, h):
5369
+ """Build `mode`'s shape as the ACTIVE SELECTION, in ISOLATION (every branch uses
5370
+ `Gimp.ChannelOps.REPLACE` against whatever was selected before, which is irrelevant -- the
5371
+ caller always starts from a cleared selection). `op_select` applies `invert`/`feather_px` to
5372
+ this shape ALONE, immediately after, before it is ever combined with an existing channel --
5373
+ see `op_select`'s own docstring for why that order matters."""
5374
+ REPLACE = Gimp.ChannelOps.REPLACE
5375
+ sample_merged = lib.optional_bool(args, 'sample_merged', True)
5376
+ Gimp.context_push()
5377
+ try:
5378
+ Gimp.context_set_feather(False)
5379
+ Gimp.context_set_antialias(True)
5380
+ threshold = lib.validate_range('threshold', args.get('threshold', 15), 0, 255)
5381
+ Gimp.context_set_sample_threshold(threshold / 255.0)
5382
+ Gimp.context_set_sample_merged(sample_merged)
5383
+ Gimp.context_set_sample_criterion(Gimp.SelectCriterion.COMPOSITE)
5384
+ # Set explicitly rather than inherited from whatever the session's own ambient context
5385
+ # happens to already hold (a GUI action, or an earlier call, could have left either at a
5386
+ # non-default value): sample_transparent False matches color_range/magic_wand's own
5387
+ # "colour" framing -- a fully transparent pixel has no colour to match, so it is excluded
5388
+ # from both the sampled target and the contiguous region, the same as GIMP's own tool
5389
+ # defaults. diagonal_neighbors False keeps magic_wand's contiguous region 4-connected
5390
+ # (up/down/left/right only), also GIMP's own tool default.
5391
+ Gimp.context_set_sample_transparent(False)
5392
+ Gimp.context_set_diagonal_neighbors(False)
5393
+ if mode == 'all':
5394
+ img.select_rectangle(REPLACE, 0, 0, w, h)
5395
+ elif mode in ('rectangle', 'ellipse'):
5396
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
5397
+ ww, hh = float(lib.require(args, 'width')), float(lib.require(args, 'height'))
5398
+ (img.select_rectangle if mode == 'rectangle' else img.select_ellipse)(REPLACE, x, y, ww, hh)
5399
+ elif mode == 'polygon':
5400
+ segs = _polygon_segments(lib.require(args, 'points'))
5401
+ img.select_polygon(REPLACE, segs)
5402
+ elif mode == 'color_range':
5403
+ drawable = _layer(img, args)
5404
+ img.select_color(REPLACE, drawable, _color_arg(img, drawable, args, sample_merged))
5405
+ elif mode == 'magic_wand':
5406
+ drawable = _layer(img, args)
5407
+ x, y = float(lib.require(args, 'x')), float(lib.require(args, 'y'))
5408
+ if sample_merged:
5409
+ # select_contiguous_color takes DOCUMENT coordinates when sampling the composite
5410
+ # (verified live) -- bounds-checked against the image itself, since GIMP silently
5411
+ # selects nothing for an out-of-image point rather than refusing it.
5412
+ if not (0 <= x < w and 0 <= y < h):
5413
+ raise ValueError('sample point (%g, %g) is outside the %dx%d image' % (x, y, w, h))
5414
+ img.select_contiguous_color(REPLACE, drawable, x, y)
5415
+ else:
5416
+ # ...but LOCAL coordinates when sampling one drawable (verified live) -- see
5417
+ # _layer_local_point's own comment.
5418
+ lx, ly = _layer_local_point(drawable, x, y)
5419
+ img.select_contiguous_color(REPLACE, drawable, lx, ly)
5420
+ elif mode == 'alpha':
5421
+ img.select_item(REPLACE, _layer(img, args))
5422
+ elif mode == 'channel':
5423
+ src = _require_channel(img, lib.require(args, 'source'))
5424
+ img.select_item(REPLACE, src)
5425
+ else:
5426
+ raise ValueError('unknown mode %r' % mode)
5427
+ finally:
5428
+ Gimp.context_pop()
5429
+
5430
+
5431
+ def op_select(args):
5432
+ """Every mode builds its shape ALONE first (`_shape_selection`, or a gradient painted directly
5433
+ into a scratch channel) -- `invert`/`feather_px` apply to that shape by itself, matching how a
5434
+ feathered marquee combines in a raster editor: draw the new region, feather IT, then union/
5435
+ subtract/intersect with whatever was already selected. Combining the raw (un-invert/feathered)
5436
+ shape first would let those two options reach into the EXISTING channel's content instead,
5437
+ which is not what either name promises."""
5438
+ img = _image(args)
5439
+ mode = lib.require(args, 'mode')
5440
+ name = args.get('name', 'Selection')
5441
+ combine = args.get('combine', 'replace')
5442
+ if combine not in _CHANNEL_OPS:
5443
+ raise ValueError('combine must be one of %s' % sorted(_CHANNEL_OPS))
5444
+ _refuse_mask_in_use(img, name)
5445
+ existing = _find_channel(img, name)
5446
+ if combine != 'replace' and existing is None:
5447
+ raise ValueError('combine=%s needs an existing channel named %r' % (combine, name))
5448
+ invert = bool(args.get('invert', False))
5449
+ feather_px = lib.validate_feather_px(args.get('feather_px', 0))
5450
+ if feather_px > 0 and mode in ('gradient_linear', 'gradient_radial'):
5451
+ raise ValueError(
5452
+ 'feather_px has no effect on a gradient (already a continuous ramp) -- omit it for '
5453
+ 'mode=%s rather than pass a value that would silently do nothing' % mode
5454
+ )
5455
+ w, h = img.get_width(), img.get_height()
5456
+
5457
+ try:
5458
+ Gimp.Selection.none(img)
5459
+ if mode in ('gradient_linear', 'gradient_radial'):
5460
+ def _paint(scratch):
5461
+ _paint_gradient_mask(scratch, mode, invert, args, w, h)
5462
+ img.select_item(Gimp.ChannelOps.REPLACE, scratch)
5463
+ _with_scratch_channel(img, w, h, _paint)
5464
+ # invert is already applied above, as a color swap done while painting.
5465
+ else:
5466
+ _shape_selection(img, mode, args, w, h)
5467
+ if invert:
5468
+ Gimp.Selection.invert(img)
5469
+ if feather_px > 0:
5470
+ Gimp.Selection.feather(img, feather_px)
5471
+
5472
+ # The active selection is now the new shape ALONE, already invert/feathered -- never yet
5473
+ # combined with whatever `name` previously held.
5474
+ if combine == 'replace':
5475
+ return _selection_to_channel(img, name)
5476
+
5477
+ def _combine(scratch):
5478
+ _capture_selection(img, w, h, scratch)
5479
+ img.select_item(Gimp.ChannelOps.REPLACE, existing)
5480
+ img.select_item(_CHANNEL_OPS[combine], scratch)
5481
+ _with_scratch_channel(img, w, h, _combine)
5482
+ return _selection_to_channel(img, name)
5483
+ finally:
5484
+ Gimp.Selection.none(img)
5485
+
5486
+
5487
+ def op_modify_mask(args):
5488
+ img = _image(args)
5489
+ name = lib.require(args, 'channel')
5490
+ out = args.get('output') or name
5491
+ _refuse_mask_in_use(img, out)
5492
+ ch = _require_channel(img, name)
5493
+ how = lib.require(args, 'op')
5494
+ try:
5495
+ img.select_item(Gimp.ChannelOps.REPLACE, ch)
5496
+ if how in ('expand', 'contract', 'border'):
5497
+ # Capped well below feather's own range -- these are morphological ops whose cost
5498
+ # scales with the radius AND the document's own megapixel count (see
5499
+ # `effective_morphology_px`'s own comment for the live measurements behind this).
5500
+ morphology_cap = lib.effective_morphology_px(img.get_width(), img.get_height())
5501
+ px = lib.validate_positive_px('px', lib.require(args, 'px'), max_px=morphology_cap)
5502
+ if how == 'expand':
5503
+ Gimp.Selection.grow(img, px)
5504
+ elif how == 'contract':
5505
+ _shrink_edge_locked(img, px)
5506
+ else:
5507
+ Gimp.Selection.border(img, px)
5508
+ elif how == 'feather':
5509
+ px = lib.validate_positive_px('px', lib.require(args, 'px'))
5510
+ Gimp.Selection.feather(img, px)
5511
+ elif how == 'invert':
5512
+ Gimp.Selection.invert(img)
5513
+ elif how == 'harden':
5514
+ _harden_selection(img)
5515
+ elif how == 'smooth':
5516
+ # Round off jaggies: feather, then re-threshold at 50%. `px` is optional here (unlike
5517
+ # the four ops above) -- a plain smoothing pass with no caller-chosen radius is still
5518
+ # meaningful, so it floors at 1.0 rather than requiring one.
5519
+ px = lib.validate_range('px', args.get('px', 0), 0, lib.MAX_FEATHER_PX)
5520
+ Gimp.Selection.feather(img, max(px, 1.0))
5521
+ _harden_selection(img)
5522
+ else:
5523
+ raise ValueError('unknown op %r' % how)
5524
+ return _selection_to_channel(img, out)
5525
+ finally:
5526
+ Gimp.Selection.none(img)
5527
+
5528
+
5529
+ _LAYER_MASK_SOURCES = {
5530
+ 'white': Gimp.AddMaskType.WHITE, 'black': Gimp.AddMaskType.BLACK,
5531
+ 'alpha': Gimp.AddMaskType.ALPHA, 'grayscale': Gimp.AddMaskType.COPY,
5532
+ }
5533
+
5534
+
5535
+ def op_layer_mask(args):
5536
+ img = _image(args)
5537
+ layer = _layer(img, args)
5538
+ how = lib.require(args, 'op')
5539
+ mask = layer.get_mask()
5540
+ if how == 'create':
5541
+ source = args.get('source', 'channel')
5542
+ # Resolve and validate EVERYTHING (the channel exists, or `source` names a real kind)
5543
+ # before touching the layer's EXISTING mask: a typo'd channel name must never destroy a
5544
+ # hand-tuned mask already in place. The new mask is built in full -- `create_mask` (a
5545
+ # candidate GimpLayerMask object, not yet attached) succeeds independently of whatever
5546
+ # mask the layer currently has -- and only once that succeeds is the old one discarded.
5547
+ if source == 'channel':
5548
+ ch = _require_channel(img, lib.require(args, 'channel'))
5549
+ img.select_item(Gimp.ChannelOps.REPLACE, ch)
5550
+ try:
5551
+ new = layer.create_mask(Gimp.AddMaskType.SELECTION)
5552
+ finally:
5553
+ Gimp.Selection.none(img)
5554
+ elif source in _LAYER_MASK_SOURCES:
5555
+ new = layer.create_mask(_LAYER_MASK_SOURCES[source])
5556
+ else:
5557
+ raise ValueError('source must be one of %s' % sorted(['channel'] + list(_LAYER_MASK_SOURCES)))
5558
+ if mask is not None:
5559
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
5560
+ if not layer.add_mask(new):
5561
+ # Best-effort: put the old mask back rather than leaving the layer maskless on top of
5562
+ # having failed to attach the new one. If this ALSO fails, the layer is left without a
5563
+ # mask either way, but the attempt costs nothing.
5564
+ if mask is not None:
5565
+ layer.add_mask(mask)
5566
+ raise lib.OpError(
5567
+ 'gimp_op_failed', 'GIMP could not attach the new mask to layer %r' % layer.get_name()
5568
+ )
5569
+ # `invert` runs AFTER add_mask -- verified live that inverting the mask object before it is
5570
+ # attached to the layer has no visible effect (the invert is silently lost).
5571
+ if lib.optional_bool(args, 'invert', False):
5572
+ new.invert(False)
5573
+ elif mask is None:
5574
+ raise ValueError('layer %r has no mask' % layer.get_name())
5575
+ elif how == 'delete':
5576
+ layer.remove_mask(Gimp.MaskApplyMode.DISCARD)
5577
+ elif how == 'apply':
5578
+ layer.remove_mask(Gimp.MaskApplyMode.APPLY)
5579
+ elif how == 'invert':
5580
+ mask.invert(False)
5581
+ else:
5582
+ raise ValueError('unknown op %r' % how)
5583
+ _drop_proxies(img.get_id())
5584
+ return {'layer': layer.get_name(), 'layer_id': layer.get_id(), 'op': how,
5585
+ 'has_mask': layer.get_mask() is not None}
5586
+
5587
+
5588
+ def _fill_color(rgb, alpha=1.0):
5589
+ return Gegl.Color.new('#%02x%02x%02x%02x' % (rgb[0], rgb[1], rgb[2], int(round(alpha * 255))))
5590
+
5591
+
5592
+ def _build_fill_gradient(stops):
5593
+ """A temporary custom gradient through `stops`: one linear segment between each pair of
5594
+ neighbors of `lib.gradient_ramp`. The caller deletes it (`Gimp.Gradient` is a resource that
5595
+ would otherwise outlive the call)."""
5596
+ ramp = lib.gradient_ramp(stops)
5597
+ count = len(ramp) - 1
5598
+ gradient = Gimp.Gradient.new('editmamei-fill-tmp')
5599
+ try:
5600
+ # GIMP clamps a segment's new right edge between its own middle and the next segment's
5601
+ # middle, so edges cannot be placed after a uniform split (a stop at 0.1 would land on
5602
+ # the first middle). Instead, grow one segment at a time: split the last segment, park
5603
+ # the two halves' middles at their outer edges, then set the edge, which can now go
5604
+ # anywhere in [previous stop, 1]. Equal offsets give a zero-width segment, a hard edge.
5605
+ for i in range(count - 1):
5606
+ gradient.segment_range_split_midpoint(i, i)
5607
+ gradient.segment_set_middle_pos(i, gradient.segment_get_left_pos(i)[-1])
5608
+ gradient.segment_set_middle_pos(i + 1, gradient.segment_get_right_pos(i + 1)[-1])
5609
+ gradient.segment_set_right_pos(i, ramp[i + 1][0])
5610
+ for i in range(count):
5611
+ (_lo, lrgb, la), (_hi, rrgb, ra) = ramp[i], ramp[i + 1]
5612
+ gradient.segment_set_left_color(i, _fill_color(lrgb, la))
5613
+ gradient.segment_set_right_color(i, _fill_color(rrgb, ra))
5614
+ gradient.segment_range_set_blending_function(0, count - 1, Gimp.GradientSegmentType.LINEAR)
5615
+ gradient.segment_range_set_coloring_type(0, count - 1, Gimp.GradientSegmentColor.RGB)
5616
+ # Segment midpoints (the blend's bias) are re-centered last: moving an edge leaves them where they were.
5617
+ for i in range(count):
5618
+ lo, hi = gradient.segment_get_left_pos(i)[-1], gradient.segment_get_right_pos(i)[-1]
5619
+ gradient.segment_set_middle_pos(i, (lo + hi) / 2.0)
5620
+ except Exception:
5621
+ gradient.delete()
5622
+ raise
5623
+ return gradient
5624
+
5625
+
5626
+ def op_fill(args):
5627
+ """Add a new canvas-sized layer filled with a solid color or a linear/radial gradient.
5628
+
5629
+ The layer goes directly above the target layer (`layer` / `layer_id`, else the selected one),
5630
+ inside its group. A gradient is rendered by GIMP's own gradient engine through a temporary
5631
+ custom gradient (one linear RGB segment per pair of stops, interpolated in perceptual sRGB
5632
+ -- see the context set-up below) that is deleted again; the gradient context is pushed and
5633
+ popped. The selection is saved, cleared while painting (it would clip the fill) and restored.
5634
+ `mask` names a channel that becomes the new layer's layer mask (white where selected)."""
5635
+ img = _image(args)
5636
+ if img.get_base_type() == Gimp.ImageBaseType.INDEXED:
5637
+ raise ValueError('cannot fill on an indexed image: convert it to RGB first (gimp_convert_image_mode)')
5638
+ layer_type = _layer_type_for(img, 'gimp_fill')
5639
+ # The new layer is canvas-sized: held to the same precision-aware cap as a new document.
5640
+ width, height = lib.validate_document_dims(img.get_width(), img.get_height(), _transform_layer_precision(img))
5641
+ opts = lib.validate_fill_args(args, width, height)
5642
+ mask_channel = _require_channel(img, opts['mask']) if opts['mask'] is not None else None
5643
+ layers = img.get_layers()
5644
+ if layers:
5645
+ target = _layer(img, args)
5646
+ parent = target.get_parent()
5647
+ position = _sibling_index(target, parent)
5648
+ else:
5649
+ parent, position = None, 0
5650
+ name = _unique_layer_name(img, opts['name'])
5651
+ layer = Gimp.Layer.new(img, name, width, height, layer_type, 100.0, Gimp.LayerMode.NORMAL)
5652
+ gradient = None
5653
+ try:
5654
+ img.insert_layer(layer, parent, position or 0)
5655
+ _assert_layer_attached(img, layer, 'the fill layer')
5656
+ with _PreservedSelection(img):
5657
+ Gimp.Selection.none(img)
5658
+ if opts['op'] == 'solid':
5659
+ Gimp.context_push()
5660
+ try:
5661
+ Gimp.context_set_foreground(_fill_color(opts['color']))
5662
+ ok = layer.fill(Gimp.FillType.FOREGROUND)
5663
+ finally:
5664
+ Gimp.context_pop()
5665
+ if not ok:
5666
+ raise lib.OpError('gimp_op_failed', 'GIMP could not fill the new layer')
5667
+ else:
5668
+ layer.fill(Gimp.FillType.TRANSPARENT)
5669
+ gradient = _build_fill_gradient(opts['stops'])
5670
+ if opts['op'] == 'linear_gradient':
5671
+ (x1, y1), (x2, y2) = opts['start'], opts['end']
5672
+ gtype = Gimp.GradientType.LINEAR
5673
+ else:
5674
+ x1, y1 = opts['center']
5675
+ x2, y2 = x1 + opts['radius'], y1
5676
+ gtype = Gimp.GradientType.RADIAL
5677
+ Gimp.context_push()
5678
+ try:
5679
+ Gimp.context_set_gradient(gradient)
5680
+ Gimp.context_set_gradient_reverse(False)
5681
+ Gimp.context_set_gradient_repeat_mode(Gimp.RepeatMode.NONE)
5682
+ Gimp.context_set_gradient_blend_color_space(Gimp.GradientBlendColorSpace.RGB_PERCEPTUAL)
5683
+ Gimp.context_set_paint_mode(Gimp.LayerMode.NORMAL)
5684
+ Gimp.context_set_opacity(100.0)
5685
+ layer.edit_gradient_fill(gtype, 0.0, False, 1, 0, False, x1, y1, x2, y2)
5686
+ finally:
5687
+ Gimp.context_pop()
5688
+ if mask_channel is not None:
5689
+ img.select_item(Gimp.ChannelOps.REPLACE, mask_channel)
5690
+ mask = layer.create_mask(Gimp.AddMaskType.SELECTION)
5691
+ if not layer.add_mask(mask):
5692
+ mask.delete() # never attached, so nothing else would free it
5693
+ raise lib.OpError('gimp_op_failed', 'GIMP could not attach the mask to the new layer')
5694
+ layer.set_mode(getattr(Gimp.LayerMode, lib.FILL_MODES[opts['mode']]))
5695
+ layer.set_opacity(opts['opacity'])
5696
+ except Exception:
5697
+ _discard_layer(img, layer)
5698
+ raise
5699
+ finally:
5700
+ if gradient is not None:
5701
+ gradient.delete()
5702
+ _drop_proxies(img.get_id())
5703
+ return {'layer_id': layer.get_id(), 'layer_name': layer.get_name(), 'op': opts['op'],
5704
+ 'mode': opts['mode'], 'opacity': opts['opacity'], 'masked': mask_channel is not None}
5705
+
5706
+
5707
+ def _gray_bytes_image(data, w, h):
5708
+ """A new GRAY image carrying `data` as its one layer's pixels. Caller deletes it. `tmp` is torn
5709
+ down on any failure after its own creation (layer build, insert, or buffer write) -- otherwise
5710
+ an exception here would leave the image orphaned in GIMP with no reference left to free it."""
5711
+ tmp = Gimp.Image.new(w, h, Gimp.ImageBaseType.GRAY)
5712
+ try:
5713
+ lay = Gimp.Layer.new(tmp, 'm', w, h, Gimp.ImageType.GRAY_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
5714
+ tmp.insert_layer(lay, None, 0)
5715
+ buf = lay.get_buffer()
5716
+ buf.set(Gegl.Rectangle.new(0, 0, w, h), "Y' u8", data)
5717
+ buf.flush()
5718
+ except Exception:
5719
+ tmp.delete()
5720
+ raise
5721
+ return tmp, lay
5722
+
5723
+
5724
+ def op_mask_preview(args):
5725
+ img = _image(args)
5726
+ ch = _require_channel(img, lib.require(args, 'channel'))
5727
+ out_path = lib.require(args, 'out_path')
5728
+ max_px = lib.validate_max_px(int(args.get('max_px', 1024)))
5729
+ w, h = img.get_width(), img.get_height()
5730
+ data = ch.get_buffer().get(Gegl.Rectangle.new(0, 0, w, h), 1.0, "Y' u8", Gegl.AbyssPolicy.NONE)
5731
+ tmp, lay = _gray_bytes_image(data, w, h)
5732
+ try:
5733
+ _scale_to_max(tmp, max_px)
5734
+ dw, dh = tmp.get_width(), tmp.get_height()
5735
+ if args.get('style', 'overlay') == 'mask':
5736
+ _export_stripped(tmp, out_path)
5737
+ return {'path': out_path, 'width': dw, 'height': dh, 'unmirrored_filters': []}
5738
+ small = lay.get_buffer().get(Gegl.Rectangle.new(0, 0, dw, dh), 1.0, "Y' u8",
5739
+ Gegl.AbyssPolicy.NONE)
5740
+ finally:
5741
+ tmp.delete()
5742
+ dup, unmirrored = _proxy_render(img, max_px)
5743
+ try:
5744
+ if dup.get_base_type() != Gimp.ImageBaseType.RGB:
5745
+ dup.convert_rgb()
5746
+ if (dup.get_width(), dup.get_height()) != (dw, dh):
5747
+ dup.scale(dw, dh)
5748
+ red = Gimp.Layer.new(dup, 'overlay', dw, dh, Gimp.ImageType.RGBA_IMAGE, 50.0,
5749
+ Gimp.LayerMode.NORMAL)
5750
+ dup.insert_layer(red, None, 0)
5751
+ Gimp.context_push()
5752
+ try:
5753
+ Gimp.context_set_foreground(Gegl.Color.new('red'))
5754
+ red.fill(Gimp.FillType.FOREGROUND)
5755
+ finally:
5756
+ Gimp.context_pop()
5757
+ m = red.create_mask(Gimp.AddMaskType.WHITE)
5758
+ red.add_mask(m)
5759
+ mb = m.get_buffer()
5760
+ # Quick Mask style: red covers what is NOT selected.
5761
+ mb.set(Gegl.Rectangle.new(0, 0, dw, dh), "Y' u8", small.translate(bytes(range(255, -1, -1))))
5762
+ mb.flush()
5763
+ dup.flatten()
5764
+ _export_stripped(dup, out_path)
5765
+ return {'path': out_path, 'width': dw, 'height': dh, 'unmirrored_filters': unmirrored}
5766
+ finally:
5767
+ dup.delete()
5768
+
5769
+
5770
+ def op_load_mask(args):
5771
+ """A grey mask image (PNG/JPEG, white = selected) -> a named channel. With a layer, the mask
5772
+ is taken to cover that layer's own bounds (e.g. a mask computed from `render_layer`).
5773
+
5774
+ Flattened onto an explicit OPAQUE BLACK layer at the bottom of the stack always -- never
5775
+ GIMP's own flatten default (the session's current background colour, verified live) -- so a
5776
+ source smaller than its own canvas, or carrying alpha, never picks up stray colour for the
5777
+ gap/transparent area: a transparent (or uncovered) pixel means "not part of the mask" NOT
5778
+ "selected". Every alpha-carrying layer's own composite space is set to RGB_PERCEPTUAL before
5779
+ flattening, so the result is value x alpha IN PERCEPTUAL SPACE (verified live: a 50%-alpha
5780
+ white pixel over black reads ~128, the plain 255 x 0.5), not a linear-light blend (which would
5781
+ read ~186 for the same input) -- a mask is a plain 0-255 coverage value, not a photographed
5782
+ light, so the perceptual (byte-arithmetic) reading is the one that matches every other mask
5783
+ value this bridge produces."""
5784
+ img = _image(args)
5785
+ name = args.get('name', 'Mask')
5786
+ _refuse_mask_in_use(img, name)
5787
+ path = lib.require(args, 'path')
5788
+ basename = os.path.basename(path)
5789
+ if not os.path.exists(path):
5790
+ raise FileNotFoundError('no file named %s to load as a mask' % basename)
5791
+ w, h = img.get_width(), img.get_height()
5792
+ if args.get('layer') or args.get('layer_id') is not None:
5793
+ layer = _layer(img, args)
5794
+ _ok, ox, oy = layer.get_offsets()
5795
+ tw, th = layer.get_width(), layer.get_height()
5796
+ else:
5797
+ ox = oy = 0
5798
+ tw, th = w, h
5799
+ load_error = None
5800
+ try:
5801
+ src = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(path))
5802
+ except Exception as e:
5803
+ src = None
5804
+ load_error = str(e)
5805
+ if src is None:
5806
+ detail = _path_free_detail(load_error)
5807
+ raise lib.OpError('gimp_op_failed', 'could not load %s as a mask%s' % (basename, detail))
5808
+ try:
5809
+ lib.validate_loaded_mask_dims(src.get_width(), src.get_height())
5810
+ if src.get_base_type() != Gimp.ImageBaseType.GRAY:
5811
+ src.convert_grayscale()
5812
+ for src_layer in src.get_layers():
5813
+ if src_layer.has_alpha():
5814
+ src_layer.set_composite_space(Gimp.LayerColorSpace.RGB_PERCEPTUAL)
5815
+ bg = Gimp.Layer.new(src, 'mask-bg', src.get_width(), src.get_height(),
5816
+ Gimp.ImageType.GRAY_IMAGE, 100.0, Gimp.LayerMode.NORMAL)
5817
+ src.insert_layer(bg, None, len(src.get_layers()))
5818
+ Gimp.context_push()
5819
+ try:
5820
+ Gimp.context_set_foreground(Gegl.Color.new('black'))
5821
+ bg.fill(Gimp.FillType.FOREGROUND)
5822
+ finally:
5823
+ Gimp.context_pop()
5824
+ if (src.get_width(), src.get_height()) != (tw, th):
5825
+ src.scale(tw, th)
5826
+ flat = src.flatten()
5827
+ data = flat.get_buffer().get(Gegl.Rectangle.new(0, 0, tw, th), 1.0, "Y' u8",
5828
+ Gegl.AbyssPolicy.NONE)
5829
+ finally:
5830
+ src.delete()
5831
+ ch = _replace_named_channel(img, name, w, h)
5832
+ x0, y0, x1, y1 = lib.compute_mask_paste_rect(ox, oy, tw, th, w, h)
5833
+ if x1 > x0 and y1 > y0:
5834
+ if (ox, oy, tw, th) == (0, 0, w, h):
5835
+ sub = data
5836
+ else:
5837
+ sub = b''.join(data[(y - oy) * tw + (x0 - ox):(y - oy) * tw + (x1 - ox)]
5838
+ for y in range(y0, y1))
5839
+ buf = ch.get_buffer()
5840
+ buf.set(Gegl.Rectangle.new(x0, y0, x1 - x0, y1 - y0), "Y' u8", sub)
5841
+ buf.flush()
5842
+ ch.update(0, 0, w, h)
5843
+ Gimp.Selection.none(img)
5844
+ _drop_proxies(img.get_id())
5845
+ selected, fraction = _channel_coverage(ch, w, h)
5846
+ return {'channel': name, 'selected_pixels': selected, 'fraction': fraction}
5847
+
5848
+
5849
+ def op_render_layer(args):
5850
+ """One layer's own pixels (not the composite), downscaled, to out_path -- the input for a mask
5851
+ that should follow that layer. Returns the layer's bounds in document pixels.
5852
+
5853
+ Rendered as if the layer were fully VISIBLE, at 100% opacity, in Normal mode -- set on the
5854
+ duplicated layer before flatten, so a hidden, partially-transparent, or blend-mode layer still
5855
+ renders its own pixels plainly rather than the (possibly empty or blended-dark) result its
5856
+ current display settings would otherwise produce. The layer's own mask and any live filters
5857
+ are still included, exactly as they would render in the document. Any remaining transparency
5858
+ (the layer's own alpha, not just visibility/opacity/mode) is flattened onto an explicit OPAQUE
5859
+ WHITE background -- not whatever the session's own ambient context background happens to be --
5860
+ since the output feeds a subject/object detector that expects an ordinary opaque photo."""
5861
+ img = _image(args)
5862
+ layer = _layer(img, args)
5863
+ out_path = lib.require(args, 'out_path')
5864
+ max_px = lib.validate_max_px(int(args.get('max_px', 2048)))
5865
+ lw, lh = layer.get_width(), layer.get_height()
5866
+ _ok, ox, oy = layer.get_offsets()
5867
+ tmp = Gimp.Image.new(lw, lh, Gimp.ImageBaseType.RGB)
5868
+ try:
5869
+ nl = Gimp.Layer.new_from_drawable(layer, tmp)
5870
+ tmp.insert_layer(nl, None, 0)
5871
+ nl.set_offsets(0, 0)
5872
+ nl.set_visible(True)
5873
+ nl.set_opacity(100.0)
5874
+ nl.set_mode(Gimp.LayerMode.NORMAL)
5875
+ bg = Gimp.Layer.new(tmp, 'render-bg', lw, lh, Gimp.ImageType.RGB_IMAGE, 100.0,
5876
+ Gimp.LayerMode.NORMAL)
5877
+ tmp.insert_layer(bg, None, 1)
5878
+ Gimp.context_push()
5879
+ try:
5880
+ Gimp.context_set_foreground(Gegl.Color.new('white'))
5881
+ bg.fill(Gimp.FillType.FOREGROUND)
5882
+ finally:
5883
+ Gimp.context_pop()
5884
+ _scale_to_max(tmp, max_px)
5885
+ tmp.flatten()
5886
+ _export_stripped(tmp, out_path)
5887
+ return {'path': out_path, 'width': tmp.get_width(), 'height': tmp.get_height(),
5888
+ 'layer_id': layer.get_id(), 'bounds': {'x': ox, 'y': oy, 'width': lw, 'height': lh}}
5889
+ finally:
5890
+ tmp.delete()
5891
+
5892
+
3053
5893
  def op_close(args):
3054
5894
  img = _image(args)
3055
5895
  _drop_proxies(img.get_id())
@@ -3067,6 +5907,11 @@ OPS = {
3067
5907
  'select_none': op_select_none, 'layer': op_layer, 'bake': op_bake,
3068
5908
  'create_document': op_create_document, 'place_image': op_place_image,
3069
5909
  'canvas': op_canvas, 'convert_image_mode': op_convert_image_mode,
5910
+ 'text': op_text, 'fonts': op_fonts,
5911
+ 'select': op_select, 'modify_mask': op_modify_mask, 'layer_mask': op_layer_mask,
5912
+ 'mask_preview': op_mask_preview, 'load_mask': op_load_mask, 'render_layer': op_render_layer,
5913
+ 'transform_layer': op_transform_layer,
5914
+ 'match_layer': op_match_layer, 'retouch': op_retouch, 'fill': op_fill, 'render_region': op_render_region,
3070
5915
  }
3071
5916
 
3072
5917