editmamei 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/backends/gimp/bridge/lib.py +833 -4
- package/dist/backends/gimp/bridge/ops.py +1943 -110
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +7 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/install.js +2 -2
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +13 -1
- package/dist/core/tool-groups.js +7 -1
- package/dist/core/tool-tiers.js +7 -1
- package/dist/install-channel.js +2 -0
- package/dist/modules/gimp/index.js +8 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +23 -16
- package/dist/telemetry/outbox.js +248 -28
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +50 -9
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +3 -3
- package/dist/tools/gimp-core-tools.js +7 -8
- package/dist/tools/gimp-document-tools.js +4 -1
- package/dist/tools/gimp-effect-tools.js +2 -2
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +35 -5
- package/dist/tools/gimp-layer-tools.js +5 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-selection-tools.js +374 -0
- package/dist/tools/gimp-shared.js +12 -0
- package/dist/tools/gimp-text-tools.js +181 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +2 -13
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/update/check.js +2 -0
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +7 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/tools/gimp-mask-tools.js +0 -102
|
@@ -181,10 +181,146 @@ BIT_DEPTHS = (8, 16)
|
|
|
181
181
|
# SOURCE document already is (crop/rotate/flip only ever shrink-or-preserve the existing canvas,
|
|
182
182
|
# so they don't need this; resize is the one op that can grow it arbitrarily from a tiny source).
|
|
183
183
|
MAX_RESIZE_SIDE_PX = 30_000
|
|
184
|
-
|
|
184
|
+
_DEFAULT_MAX_RESIZE_MEGAPIXELS = 250
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def megapixel_cap_from_env(raw, default=_DEFAULT_MAX_RESIZE_MEGAPIXELS):
|
|
188
|
+
"""A host-supplied (`EM_GIMP_MAX_MEGAPIXELS`) ceiling that can only LOWER `default` -- a memory-
|
|
189
|
+
constrained host (e.g. a small container) tightens every pixel-growing op's cap this way. Anything
|
|
190
|
+
that is not a finite positive number below `default` (unset, empty, junk, NaN/inf, zero/negative,
|
|
191
|
+
or an attempt to RAISE it) is ignored and `default` is returned unchanged."""
|
|
192
|
+
if not raw:
|
|
193
|
+
return default
|
|
194
|
+
try:
|
|
195
|
+
value = float(raw)
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return default
|
|
198
|
+
if not math.isfinite(value) or value <= 0 or value >= default:
|
|
199
|
+
return default
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
MAX_RESIZE_MEGAPIXELS = megapixel_cap_from_env(os.environ.get('EM_GIMP_MAX_MEGAPIXELS'))
|
|
185
204
|
|
|
186
205
|
MAX_FEATHER_PX = 1000
|
|
187
206
|
|
|
207
|
+
# gimp_modify_selection's expand/contract/border radius cap -- much lower than MAX_FEATHER_PX.
|
|
208
|
+
# `Gimp.Selection.grow`/`shrink`/`border` are morphological (structuring-element) operations whose
|
|
209
|
+
# cost scales with the radius, unlike `feather` (a GEGL blur, flat ~0.5s regardless of radius on a
|
|
210
|
+
# 24MP image, measured live). This is the cap for a document at or below MORPHOLOGY_BASELINE_MP;
|
|
211
|
+
# a bigger document gets a SMALLER cap -- see `effective_morphology_px`.
|
|
212
|
+
MAX_MORPHOLOGY_PX = 150
|
|
213
|
+
|
|
214
|
+
# Measured live at px=150 (border, the most expensive of the three): ~30s at 24MP (6016x4000),
|
|
215
|
+
# ~73.5s at 100MP (14000x7143), and a GIMP session OUTRIGHT TIMEOUT past 180s at ~217MP
|
|
216
|
+
# (30000x7228, this bridge's own largest allowed document -- MAX_RESIZE_SIDE_PX/
|
|
217
|
+
# MAX_RESIZE_MEGAPIXELS). Cost does not scale linearly with megapixels (100MP was only ~2.45x the
|
|
218
|
+
# 24MP cost for a ~4.17x bigger image), but it very much does not fit inside
|
|
219
|
+
# gimp_modify_selection's budget either, so the cap itself has to shrink as the document grows.
|
|
220
|
+
MORPHOLOGY_BASELINE_MP = 24.0
|
|
221
|
+
# A document-size floor so a huge document still gets a meaningfully large morphology radius
|
|
222
|
+
# rather than being squeezed to single-digit pixels.
|
|
223
|
+
MIN_MORPHOLOGY_PX = 10
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def effective_morphology_px(width, height):
|
|
227
|
+
"""The actual expand/contract/border cap for a `width`x`height` document: `MAX_MORPHOLOGY_PX`
|
|
228
|
+
at or below `MORPHOLOGY_BASELINE_MP`, shrinking as 1/megapixels above it (simple and, per the
|
|
229
|
+
live measurements on `MAX_MORPHOLOGY_PX`'s own comment, SAFELY conservative -- the real cost
|
|
230
|
+
curve is sub-linear in megapixels, so this reduces the cap by more than the measured cost
|
|
231
|
+
alone would require). Verified live at the two points that matter: at ~100MP the scaled cap
|
|
232
|
+
(36px) cost ~11.8s; at ~217MP (this bridge's largest allowed document) the scaled cap (17px)
|
|
233
|
+
cost ~27.2s -- both comfortably inside gimp_modify_selection's 45s budget with its own margin,
|
|
234
|
+
even though a large document also carries a substantial FIXED per-call cost that shrinking
|
|
235
|
+
`px` alone cannot remove (measured ~13-17s of that 27.2s at ~217MP comes from px=5 alone,
|
|
236
|
+
i.e. just handling a document that size, before any morphology radius is even considered)."""
|
|
237
|
+
megapixels = (width * height) / 1_000_000.0
|
|
238
|
+
if megapixels <= MORPHOLOGY_BASELINE_MP:
|
|
239
|
+
return MAX_MORPHOLOGY_PX
|
|
240
|
+
scaled = MAX_MORPHOLOGY_PX * MORPHOLOGY_BASELINE_MP / megapixels
|
|
241
|
+
return max(MIN_MORPHOLOGY_PX, min(MAX_MORPHOLOGY_PX, round(scaled)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# gimp_transform_layer's scale/scale_x_percent/scale_y_percent bound -- generous enough for any
|
|
245
|
+
# real use (100x in either direction) while still keeping a single call's resulting layer size
|
|
246
|
+
# bounded alongside validate_resize_dims' own check on the actual pixel dimensions.
|
|
247
|
+
TRANSFORM_LAYER_MIN_SCALE_PERCENT = 1
|
|
248
|
+
TRANSFORM_LAYER_MAX_SCALE_PERCENT = 10_000
|
|
249
|
+
|
|
250
|
+
# gimp_transform_layer's `interpolation` choices -- the GIMP resampling filters this bridge
|
|
251
|
+
# exposes, by name. The gi-dependent mapping to the real `Gimp.InterpolationType` enum members
|
|
252
|
+
# lives in ops.py (this module stays gi-free).
|
|
253
|
+
TRANSFORM_LAYER_INTERPOLATIONS = ('none', 'linear', 'cubic', 'nohalo', 'lohalo')
|
|
254
|
+
|
|
255
|
+
# gimp_transform_layer's `free` op: offset_x/offset_y bound -- a sanity ceiling on the final
|
|
256
|
+
# absolute translation, well past MAX_RESIZE_SIDE_PX (30000) so any real placement fits, but
|
|
257
|
+
# tight enough that a wildly out-of-range value is refused here rather than only by the derived
|
|
258
|
+
# predicted-origin check (`_validated_move_offset`) it also feeds into.
|
|
259
|
+
TRANSFORM_LAYER_MAX_OFFSET_PX = 100_000
|
|
260
|
+
|
|
261
|
+
# gimp_transform_layer's own op -> the field names that op reads, the same role
|
|
262
|
+
# `type_fields`/`FILTER_COMMON_KEYS` play for gimp_add_adjustment/gimp_add_effect above --
|
|
263
|
+
# `reject_foreign_transform_fields` refuses any OTHER declared field a caller sent (e.g. a
|
|
264
|
+
# PS-style flat `delta_x`, or `skew_h_degrees` on op=scale), so a typo validates instead of
|
|
265
|
+
# silently being ignored by that op's own handler.
|
|
266
|
+
TRANSFORM_LAYER_COMMON_KEYS = frozenset(('image', 'op', 'layer', 'layer_id', 'interpolation'))
|
|
267
|
+
|
|
268
|
+
TRANSFORM_LAYER_OP_FIELDS = {
|
|
269
|
+
'fit': ('mode',),
|
|
270
|
+
'scale': ('scale_percent', 'scale_x_percent', 'scale_y_percent'),
|
|
271
|
+
'move': ('delta', 'absolute', 'center_on'),
|
|
272
|
+
'rotate': ('degrees',),
|
|
273
|
+
'flip': ('axis',),
|
|
274
|
+
'skew': ('skew_h_degrees', 'skew_v_degrees'),
|
|
275
|
+
'free': ('scale_x_percent', 'scale_y_percent', 'degrees', 'offset_x', 'offset_y'),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def reject_foreign_transform_fields(op, args):
|
|
280
|
+
"""Refuse any field `op` does not read -- the gimp_transform_layer analogue of
|
|
281
|
+
`reject_foreign_fields` above, over `TRANSFORM_LAYER_OP_FIELDS` instead of the filter-type
|
|
282
|
+
tables. `op`'s own schema is flat (one property per name across every op), so without this a
|
|
283
|
+
field meant for a DIFFERENT op (`skew_h_degrees` on op=scale, or PS's flat `delta_x` instead
|
|
284
|
+
of this tool's nested `delta`) validates, is ignored by that op's own handler, and silently
|
|
285
|
+
does nothing. None values are ignored (an omitted field)."""
|
|
286
|
+
own = TRANSFORM_LAYER_OP_FIELDS.get(op, ())
|
|
287
|
+
foreign = sorted(
|
|
288
|
+
k for k, v in args.items()
|
|
289
|
+
if v is not None and k not in TRANSFORM_LAYER_COMMON_KEYS and k not in own
|
|
290
|
+
)
|
|
291
|
+
if foreign:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
"op '%s' does not use field(s) %s; its fields are: %s"
|
|
294
|
+
% (op, ', '.join(foreign), ', '.join(own) or '(none)')
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def ceil_with_margin(value):
|
|
299
|
+
"""`value` rounded UP to the next whole pixel, plus one more -- the conservative integer a
|
|
300
|
+
cap check validates against for a float `transformed_bounds` predicts, matching the extra
|
|
301
|
+
pixel GIMP's own ADJUST transform-resize can add by rounding each edge of a rotated/sheared
|
|
302
|
+
bounding box outward independently (floor the min edge, ceil the max edge), which can land
|
|
303
|
+
the ACTUAL resulting integer width/height one pixel past a plain `ceil` of the float width.
|
|
304
|
+
Never used for the transform's own corner coordinates (those stay exact floats) -- only for
|
|
305
|
+
deciding whether to refuse BEFORE any pixel moves."""
|
|
306
|
+
return int(math.ceil(value)) + 1
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def transform_layer_precision_bucket(precision_nick):
|
|
310
|
+
"""`Gimp.Precision.value_nick` (e.g. 'u8-non-linear', 'u16-perceptual', 'float-linear')
|
|
311
|
+
bucketed into the '8'/'16'/'32' strings `validate_document_dims`'s own
|
|
312
|
+
`DOCUMENT_MEGAPIXEL_CAP` is keyed by -- gimp_transform_layer validates a layer's predicted
|
|
313
|
+
size against the SAME precision-aware cap gimp_create_document does, rather than always
|
|
314
|
+
assuming 8-bit. u16 and half bucket to '16' (2 bytes/channel); every other precision (u32,
|
|
315
|
+
float, double) buckets to '32' (4+ bytes/channel, the most conservative bucket) -- this
|
|
316
|
+
bridge has no DOCUMENT_MEGAPIXEL_CAP entry finer than that."""
|
|
317
|
+
nick = precision_nick.lower()
|
|
318
|
+
if nick.startswith('u8'):
|
|
319
|
+
return '8'
|
|
320
|
+
if nick.startswith('u16') or nick.startswith('half'):
|
|
321
|
+
return '16'
|
|
322
|
+
return '32'
|
|
323
|
+
|
|
188
324
|
|
|
189
325
|
def require(args, name):
|
|
190
326
|
"""Fetch a required field from an op's `args`, raising ValueError naming it -- the
|
|
@@ -270,7 +406,7 @@ def validate_resize_dims(width, height):
|
|
|
270
406
|
raise ValueError('width and height must each be at most %d px' % MAX_RESIZE_SIDE_PX)
|
|
271
407
|
megapixels = (width * height) / 1_000_000.0
|
|
272
408
|
if megapixels > MAX_RESIZE_MEGAPIXELS:
|
|
273
|
-
raise ValueError('resize target must be at most %
|
|
409
|
+
raise ValueError('resize target must be at most %g MP' % MAX_RESIZE_MEGAPIXELS)
|
|
274
410
|
return width, height
|
|
275
411
|
|
|
276
412
|
|
|
@@ -281,7 +417,13 @@ def validate_resize_dims(width, height):
|
|
|
281
417
|
# that picks its own bit depth up front (`open`/`resize`/etc. all work on whatever precision an
|
|
282
418
|
# already-open image happens to be), so it is the one place this DoS floor needs to vary by
|
|
283
419
|
# precision rather than assuming 8-bit throughout.
|
|
284
|
-
|
|
420
|
+
# A lowered EM_GIMP_MAX_MEGAPIXELS scales the 16/32-bit caps down with the 8-bit one, never above
|
|
421
|
+
# their own defaults.
|
|
422
|
+
DOCUMENT_MEGAPIXEL_CAP = {
|
|
423
|
+
'8': MAX_RESIZE_MEGAPIXELS,
|
|
424
|
+
'16': min(125, MAX_RESIZE_MEGAPIXELS / 2),
|
|
425
|
+
'32': min(60, MAX_RESIZE_MEGAPIXELS / 4),
|
|
426
|
+
}
|
|
285
427
|
|
|
286
428
|
|
|
287
429
|
def validate_document_dims(width, height, precision='8'):
|
|
@@ -295,14 +437,182 @@ def validate_document_dims(width, height, precision='8'):
|
|
|
295
437
|
cap = DOCUMENT_MEGAPIXEL_CAP[precision]
|
|
296
438
|
megapixels = (width * height) / 1_000_000.0
|
|
297
439
|
if megapixels > cap:
|
|
298
|
-
raise ValueError(
|
|
440
|
+
raise ValueError(
|
|
441
|
+
'%s %s-bit document must be at most %g MP'
|
|
442
|
+
% ('an' if precision == '8' else 'a', precision, cap)
|
|
443
|
+
)
|
|
299
444
|
return width, height
|
|
300
445
|
|
|
301
446
|
|
|
447
|
+
def validate_group_total_pixels(sizes, precision='8'):
|
|
448
|
+
"""A GROUP transform allocates every non-group descendant's own result buffer, so the SUM of
|
|
449
|
+
their predicted pixel counts (`sizes`: (width, height) pairs) must fit the same
|
|
450
|
+
precision-aware megapixel ceiling `validate_document_dims` applies to a single layer."""
|
|
451
|
+
cap = DOCUMENT_MEGAPIXEL_CAP[precision]
|
|
452
|
+
megapixels = sum(w * h for w, h in sizes) / 1_000_000.0
|
|
453
|
+
if megapixels > cap:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"the group's layers would total %.1f MP after this transform; the %s-bit limit is %d MP"
|
|
456
|
+
% (megapixels, precision, cap)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
302
460
|
def validate_feather_px(value):
|
|
303
461
|
return validate_range('feather_px', value, 0.0, MAX_FEATHER_PX)
|
|
304
462
|
|
|
305
463
|
|
|
464
|
+
# ---- match_layer: statistics transfer as per-channel curves ---------------------------------
|
|
465
|
+
#
|
|
466
|
+
# Everything here is pure (no gi). Means and standard deviations are in GIMP's own histogram
|
|
467
|
+
# units (0-255 in the image's perceptual encoding, the same space a gimp:curves filter works in).
|
|
468
|
+
|
|
469
|
+
MATCH_TAG = 'match_layer'
|
|
470
|
+
MATCH_MODES = ('both', 'color', 'tone')
|
|
471
|
+
MATCH_REFERENCES = ('surround', 'below')
|
|
472
|
+
MATCH_DEFAULT_STRENGTH = 70.0
|
|
473
|
+
MATCH_MIN_PIXELS = 500
|
|
474
|
+
MATCH_GAIN_MIN = 0.5
|
|
475
|
+
MATCH_GAIN_MAX = 2.0
|
|
476
|
+
MATCH_MAX_EDGE_CONTRACT_PX = 20
|
|
477
|
+
MATCH_MAX_EDGE_FEATHER_PX = 50
|
|
478
|
+
MATCH_SURROUND_MIN_PX = 16
|
|
479
|
+
MATCH_SURROUND_MAX_PX = 400
|
|
480
|
+
# A std-dev below this (0-255 units) is treated as a flat region: its contrast ratio is
|
|
481
|
+
# meaningless, so the gain stays 1 and only the mean moves.
|
|
482
|
+
MATCH_FLAT_SD = 0.5
|
|
483
|
+
# Spacing of the helper points that keep a clamped curve on its line (see match_curve_points).
|
|
484
|
+
MATCH_CURVE_STEP = 16.0
|
|
485
|
+
# Measuring runs on a copy of the layer's region scaled so its long side is at most this: means
|
|
486
|
+
# and spreads survive the downscale, and growing the surround ring stays cheap at any size.
|
|
487
|
+
MATCH_MEASURE_MAX_SIDE = 2048
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def default_surround_px(layer_width, layer_height):
|
|
491
|
+
"""About 12% of the layer's longer side, clamped to 16..400 px."""
|
|
492
|
+
px = int(round(0.12 * max(layer_width, layer_height)))
|
|
493
|
+
return max(MATCH_SURROUND_MIN_PX, min(MATCH_SURROUND_MAX_PX, px))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def validate_match_args(args, layer_width, layer_height):
|
|
497
|
+
"""The validated, defaulted `match_layer` options as a dict:
|
|
498
|
+
match/strength/reference/surround_px/edge_contract_px/edge_feather_px. `strength` is returned
|
|
499
|
+
as a 0..1 fraction under `s` as well."""
|
|
500
|
+
match = validate_choice('match', args.get('match') or 'both', MATCH_MODES)
|
|
501
|
+
reference = validate_choice('reference', args.get('reference') or 'surround', MATCH_REFERENCES)
|
|
502
|
+
strength = args.get('strength')
|
|
503
|
+
strength = validate_range(
|
|
504
|
+
'strength', MATCH_DEFAULT_STRENGTH if strength is None else strength, 0.0, 100.0
|
|
505
|
+
)
|
|
506
|
+
surround = args.get('surround_px')
|
|
507
|
+
surround = (
|
|
508
|
+
default_surround_px(layer_width, layer_height)
|
|
509
|
+
if surround is None
|
|
510
|
+
else validate_int_range('surround_px', surround, 1, MATCH_SURROUND_MAX_PX)
|
|
511
|
+
)
|
|
512
|
+
contract = args.get('edge_contract_px')
|
|
513
|
+
contract = validate_int_range(
|
|
514
|
+
'edge_contract_px', 0 if contract is None else contract, 0, MATCH_MAX_EDGE_CONTRACT_PX
|
|
515
|
+
)
|
|
516
|
+
feather = args.get('edge_feather_px')
|
|
517
|
+
feather = validate_int_range(
|
|
518
|
+
'edge_feather_px', 0 if feather is None else feather, 0, MATCH_MAX_EDGE_FEATHER_PX
|
|
519
|
+
)
|
|
520
|
+
return {
|
|
521
|
+
'match': match, 'reference': reference, 'strength': strength, 's': strength / 100.0,
|
|
522
|
+
'surround_px': surround, 'edge_contract_px': contract, 'edge_feather_px': feather,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def match_transfer(mu_src, sd_src, mu_ref, sd_ref, s):
|
|
527
|
+
"""The Reinhard-style linear map moving the source's mean/std toward the reference's, scaled
|
|
528
|
+
by `s` (0..1): out = (in - mu_src) * gain + mu_src + shift, with
|
|
529
|
+
gain = clamp(1 + (sd_ref / sd_src - 1) * s, 0.5, 2.0) and shift = (mu_ref - mu_src) * s.
|
|
530
|
+
A flat source (or reference) keeps gain 1. Returns {gain, shift, mean_after, std_after};
|
|
531
|
+
the `_after` values ignore the 0-255 clamp of the curve."""
|
|
532
|
+
if sd_src < MATCH_FLAT_SD or sd_ref < MATCH_FLAT_SD:
|
|
533
|
+
ratio = 1.0
|
|
534
|
+
else:
|
|
535
|
+
ratio = sd_ref / sd_src
|
|
536
|
+
gain = max(MATCH_GAIN_MIN, min(MATCH_GAIN_MAX, 1.0 + (ratio - 1.0) * s))
|
|
537
|
+
shift = (mu_ref - mu_src) * s
|
|
538
|
+
return {
|
|
539
|
+
'gain': gain, 'shift': shift,
|
|
540
|
+
'mean_after': mu_src + shift, 'std_after': sd_src * gain,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def match_curve_points(mu_src, gain, shift):
|
|
545
|
+
"""The transfer map as gimp:curves [input, output] points (0-255, strictly ascending input).
|
|
546
|
+
The line y = gain * (x - mu_src) + mu_src + shift is clamped to 0..255. Unclamped it is just
|
|
547
|
+
its two endpoints (a two-point curve is exactly straight; gain 1 and shift 0 gives
|
|
548
|
+
[[0, 0], [255, 255]]). Where the line meets the clamp, a point is added there and helper
|
|
549
|
+
points every MATCH_CURVE_STEP along the sloped part: GIMP draws a smooth spline through the
|
|
550
|
+
points, and without the helpers it bows away from the line (measured: ~6 levels at the
|
|
551
|
+
middle of a clamped curve)."""
|
|
552
|
+
def y_at(x):
|
|
553
|
+
return gain * (x - mu_src) + mu_src + shift
|
|
554
|
+
|
|
555
|
+
def clamp(y):
|
|
556
|
+
return max(0.0, min(255.0, y))
|
|
557
|
+
|
|
558
|
+
y0, y1 = y_at(0.0), y_at(255.0)
|
|
559
|
+
xs = [0.0, 255.0]
|
|
560
|
+
if y0 < 0.0 or y1 > 255.0:
|
|
561
|
+
# y is strictly increasing (gain > 0): it crosses 0 once and 255 once.
|
|
562
|
+
low = 0.0 if y0 >= 0.0 else mu_src - (mu_src + shift) / gain
|
|
563
|
+
high = 255.0 if y1 <= 255.0 else mu_src + (255.0 - mu_src - shift) / gain
|
|
564
|
+
knots = [x for x in (low, high) if MATCH_CURVE_STEP / 4 < x < 255.0 - MATCH_CURVE_STEP / 4]
|
|
565
|
+
xs.extend(knots)
|
|
566
|
+
# Helpers fill the sloped part [low, high] only, kept clear of the knots.
|
|
567
|
+
x = MATCH_CURVE_STEP * (int(low // MATCH_CURVE_STEP) + 1)
|
|
568
|
+
while x < 255.0:
|
|
569
|
+
if low + MATCH_CURVE_STEP / 4 < x < high - MATCH_CURVE_STEP / 4 and x > MATCH_CURVE_STEP / 4:
|
|
570
|
+
xs.append(float(x))
|
|
571
|
+
x += MATCH_CURVE_STEP
|
|
572
|
+
xs = sorted(set(xs))
|
|
573
|
+
return [[round(x, 2), round(clamp(y_at(x)), 2)] for x in xs]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def match_measure_scale(width, height):
|
|
577
|
+
"""The factor (0 < f <= 1) a measuring copy of a `width` x `height` region is scaled by."""
|
|
578
|
+
longest = max(width, height)
|
|
579
|
+
return 1.0 if longest <= MATCH_MEASURE_MAX_SIDE else MATCH_MEASURE_MAX_SIDE / float(longest)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def match_scaled_px(px, factor):
|
|
583
|
+
"""A pixel distance on a copy scaled by `factor`, never below 1 for a non-zero distance."""
|
|
584
|
+
return 0 if px <= 0 else max(1, int(round(px * factor)))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def match_full_res_count(count, factor):
|
|
588
|
+
"""A pixel count measured on a copy scaled by `factor`, as the full-resolution count."""
|
|
589
|
+
return int(round(count / (factor * factor)))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def histogram_scale(nick):
|
|
593
|
+
"""What a Drawable.histogram mean/std must be multiplied by to read 0-255: GIMP reports them
|
|
594
|
+
in 0-255 for 8-bit precisions and in 0-1 for every higher one."""
|
|
595
|
+
return 1.0 if nick.startswith('u8') else 255.0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def is_linear_precision(nick):
|
|
599
|
+
"""Whether a GIMP precision nick ('u8-non-linear', 'float-linear', ...) stores linear light."""
|
|
600
|
+
return nick.endswith('-linear') and not nick.endswith('-non-linear')
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def match_roi(layer_x, layer_y, layer_width, layer_height, canvas_width, canvas_height, pad):
|
|
604
|
+
"""The layer's box grown by `pad` and clipped to the canvas, as (x, y, width, height); None
|
|
605
|
+
when the layer's own box does not touch the canvas."""
|
|
606
|
+
if (layer_x >= canvas_width or layer_y >= canvas_height
|
|
607
|
+
or layer_x + layer_width <= 0 or layer_y + layer_height <= 0):
|
|
608
|
+
return None
|
|
609
|
+
x0 = max(0, layer_x - pad)
|
|
610
|
+
y0 = max(0, layer_y - pad)
|
|
611
|
+
x1 = min(canvas_width, layer_x + layer_width + pad)
|
|
612
|
+
y1 = min(canvas_height, layer_y + layer_height + pad)
|
|
613
|
+
return x0, y0, x1 - x0, y1 - y0
|
|
614
|
+
|
|
615
|
+
|
|
306
616
|
_HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
|
|
307
617
|
|
|
308
618
|
# gimp_canvas's fill choices: the same white/black/transparent LAYER_FILLS a new layer gets, plus
|
|
@@ -319,6 +629,57 @@ def validate_canvas_fill(value):
|
|
|
319
629
|
)
|
|
320
630
|
|
|
321
631
|
|
|
632
|
+
def validate_hex_color(name, value):
|
|
633
|
+
"""A '#rrggbb' hex color -- the same `_HEX_COLOR_RE` gimp_canvas's own fill validates against,
|
|
634
|
+
reused here so a color field never silently reaches `Gegl.Color.new` with a string it cannot
|
|
635
|
+
parse (which would otherwise fall back to black)."""
|
|
636
|
+
if not isinstance(value, str) or not _HEX_COLOR_RE.fullmatch(value):
|
|
637
|
+
raise ValueError('%s must be a "#rrggbb" hex color, got %r' % (name, value))
|
|
638
|
+
return value
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _round_half_up(value):
|
|
642
|
+
"""Round half away from zero, unlike Python's builtin `round()` (round-half-to-even:
|
|
643
|
+
`round(0.5) == 0`, `round(2.5) == 2`) -- surprising for a user-facing pixel radius, where
|
|
644
|
+
"4.5 px" should become 5, not 4."""
|
|
645
|
+
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def validate_positive_px(name, value, max_px=MAX_FEATHER_PX):
|
|
649
|
+
"""A strictly positive pixel radius (expand/contract/border/feather). Rounds half-up to the
|
|
650
|
+
nearest whole pixel FIRST, then refuses anything that is not strictly positive or exceeds
|
|
651
|
+
max_px -- so a value that ROUNDS to 0 (e.g. 0.3) is refused, same as an outright 0 or
|
|
652
|
+
negative, rather than silently accepted as a no-op radius."""
|
|
653
|
+
rounded = int(_round_half_up(float(value)))
|
|
654
|
+
if not 0 < rounded <= max_px:
|
|
655
|
+
raise ValueError('%s must round to a value greater than 0 and at most %s' % (name, max_px))
|
|
656
|
+
return rounded
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# load_mask's own DoS floor on a SOURCE mask image's dimensions. Checked right after
|
|
660
|
+
# `Gimp.file_load` decodes the file (GIMP's Python environment has no cheap way to read a PNG/
|
|
661
|
+
# JPEG header's own width/height without decoding -- GdkPixbuf is not bound in this environment),
|
|
662
|
+
# so it bounds the IN-MEMORY size before the heavier work that follows (alpha compositing, scale,
|
|
663
|
+
# flatten), not the decode itself. Reuses the same cap `validate_resize_dims` applies to a resize
|
|
664
|
+
# target, since both are "how big a single in-memory image may get" limits.
|
|
665
|
+
def validate_loaded_mask_dims(width, height):
|
|
666
|
+
return validate_resize_dims(width, height)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def compute_mask_paste_rect(ox, oy, tw, th, img_width, img_height):
|
|
670
|
+
"""Where a `tw`x`th` mask (already scaled to its target -- the whole image, or one layer's own
|
|
671
|
+
bounds, at offset `(ox, oy)`) actually lands inside the `img_width`x`img_height` canvas: the
|
|
672
|
+
intersection of [ox, ox+tw) x [oy, oy+th) with [0, img_width) x [0, img_height). Pure and
|
|
673
|
+
gi-free so the off-canvas cases (a negative offset, or a layer hanging off the right/bottom
|
|
674
|
+
edge) are unit-tested without GIMP. Returns (x0, y0, x1, y1); x1 <= x0 or y1 <= y0 means the
|
|
675
|
+
mask lands ENTIRELY off-canvas (nothing to paste -- the channel stays all-black there)."""
|
|
676
|
+
x0 = max(0, ox)
|
|
677
|
+
y0 = max(0, oy)
|
|
678
|
+
x1 = min(img_width, ox + tw)
|
|
679
|
+
y1 = min(img_height, oy + th)
|
|
680
|
+
return x0, y0, x1, y1
|
|
681
|
+
|
|
682
|
+
|
|
322
683
|
# gimp_canvas's anchor grid -> the fraction of the GROWTH (new size minus old size) that lands
|
|
323
684
|
# BEFORE the existing content on each axis. 0.0 pins that edge (no padding there); 1.0 puts all
|
|
324
685
|
# the padding there instead; 0.5 splits it evenly. Keyed by the full 3x3 grid a Photoshop-style
|
|
@@ -350,6 +711,69 @@ def canvas_anchor_offset(anchor, old_width, old_height, new_width, new_height):
|
|
|
350
711
|
)
|
|
351
712
|
|
|
352
713
|
|
|
714
|
+
# EXIF Orientation (1-8) -> the steps that turn the stored pixels upright, in order. Each step is
|
|
715
|
+
# 'cw90' / 'cw180' / 'cw270' (clockwise rotation), 'flip_h' (mirror left-right) or 'flip_v'
|
|
716
|
+
# (mirror top-bottom). 1 (normal) needs none.
|
|
717
|
+
EXIF_ORIENTATION_STEPS = {
|
|
718
|
+
1: (),
|
|
719
|
+
2: ('flip_h',),
|
|
720
|
+
3: ('cw180',),
|
|
721
|
+
4: ('flip_v',),
|
|
722
|
+
5: ('cw90', 'flip_h'),
|
|
723
|
+
6: ('cw90',),
|
|
724
|
+
7: ('cw90', 'flip_v'),
|
|
725
|
+
8: ('cw270',),
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def parse_exif_orientation(raw):
|
|
730
|
+
"""The EXIF Orientation value (1-8) from a metadata tag's raw value, or None when it is
|
|
731
|
+
missing, non-numeric or out of range -- a broken tag is ignored, never an error."""
|
|
732
|
+
try:
|
|
733
|
+
value = int(str(raw).strip())
|
|
734
|
+
except (TypeError, ValueError):
|
|
735
|
+
return None
|
|
736
|
+
return value if value in EXIF_ORIENTATION_STEPS else None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def exif_orientation_steps(orientation):
|
|
740
|
+
"""The upright-making steps for an EXIF Orientation value; () for None, 1 or anything else."""
|
|
741
|
+
return EXIF_ORIENTATION_STEPS.get(orientation, ())
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
EXIF_ORIENTATION_TAG = 'Exif.Image.Orientation'
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def read_exif_orientation(metadata):
|
|
748
|
+
"""The orientation (1-8) a GimpMetadata-like object carries, or None when `metadata` is None,
|
|
749
|
+
has no usable tag, or reading it raises."""
|
|
750
|
+
if metadata is None:
|
|
751
|
+
return None
|
|
752
|
+
try:
|
|
753
|
+
return parse_exif_orientation(metadata.try_get_tag_string(EXIF_ORIENTATION_TAG))
|
|
754
|
+
except Exception:
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# Every tag a viewer may read orientation from; the EXIF one is the one this bridge applies.
|
|
759
|
+
ORIENTATION_TAGS = (EXIF_ORIENTATION_TAG, 'Xmp.tiff.Orientation', 'Exif.Thumbnail.Orientation')
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def clear_exif_orientation(metadata):
|
|
763
|
+
"""Removes the orientation tags from `metadata` (absent reads as normal); True when the EXIF
|
|
764
|
+
one was cleared. Clearing is used rather than writing 1: GIMP's metadata object can silently
|
|
765
|
+
ignore a write of that tag while still reporting success."""
|
|
766
|
+
cleared = False
|
|
767
|
+
for tag in ORIENTATION_TAGS:
|
|
768
|
+
try:
|
|
769
|
+
ok = bool(metadata.try_clear_tag(tag))
|
|
770
|
+
except Exception:
|
|
771
|
+
ok = False
|
|
772
|
+
if tag == EXIF_ORIENTATION_TAG:
|
|
773
|
+
cleared = ok
|
|
774
|
+
return cleared
|
|
775
|
+
|
|
776
|
+
|
|
353
777
|
def pct_to_unit(name, value, lo=-100.0, hi=100.0):
|
|
354
778
|
"""User-facing -100..100 (or a narrower lo..hi) percent-like value -> the -1..1 unit
|
|
355
779
|
GEGL/GIMP properties in this family use."""
|
|
@@ -1358,6 +1782,155 @@ def validate_effect_transform(op_name, operation, filter_name, new_params):
|
|
|
1358
1782
|
)
|
|
1359
1783
|
|
|
1360
1784
|
|
|
1785
|
+
|
|
1786
|
+
# ---- gimp_transform_layer: pure geometry math (fit/fill scale, matrix composition, bounds) ----
|
|
1787
|
+
#
|
|
1788
|
+
# A layer-level affine transform (scale/rotate/skew/free), distinct from the whole-CANVAS
|
|
1789
|
+
# geometry ops above and from the direction/position-dependent EFFECT param remapping above.
|
|
1790
|
+
# `Item.transform_matrix` (ops.py) applies the given 3x3 matrix directly in ABSOLUTE
|
|
1791
|
+
# document-pixel coordinates -- verified live (GIMP 3.2.6): a matrix built by
|
|
1792
|
+
# `compose_layer_matrix` below, applied to a layer at a known offset, moved it to exactly the
|
|
1793
|
+
# bounding box this module's own `transformed_bounds` predicts for the same matrix and corners
|
|
1794
|
+
# (within floating-point rounding). So every matrix built here already carries whatever
|
|
1795
|
+
# translation is needed to anchor the transform at the layer's own center; there is no separate
|
|
1796
|
+
# "local" coordinate frame to convert into or out of first.
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def fit_scale_fraction(layer_width, layer_height, canvas_width, canvas_height, mode):
|
|
1800
|
+
"""The uniform scale fraction (1.0 = 100%) that makes a `layer_width` x `layer_height`
|
|
1801
|
+
rectangle fit inside (mode='fit', letterbox -- the SMALLER of the two axis ratios) or fill
|
|
1802
|
+
(mode='fill', cover -- the LARGER) a `canvas_width` x `canvas_height` canvas, preserving
|
|
1803
|
+
aspect. `op_transform_layer`'s `fit` scales by this fraction and centers the result; fitting
|
|
1804
|
+
an already-fitted layer computes a fraction of 1.0, a no-op scale -- what makes that op
|
|
1805
|
+
idempotent."""
|
|
1806
|
+
if mode not in ('fit', 'fill'):
|
|
1807
|
+
raise ValueError("mode must be one of 'fit', 'fill'")
|
|
1808
|
+
width_ratio = canvas_width / float(layer_width)
|
|
1809
|
+
height_ratio = canvas_height / float(layer_height)
|
|
1810
|
+
return min(width_ratio, height_ratio) if mode == 'fit' else max(width_ratio, height_ratio)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _mat_mul(a, b):
|
|
1814
|
+
"""3x3 matrix product, both `a` and `b` flat row-major 9-lists (the same layout
|
|
1815
|
+
`Item.transform_matrix`'s own 9 positional args use), applied to a column vector [x,y,1] as
|
|
1816
|
+
p' = a*(b*p) -- `b` is the transform applied FIRST."""
|
|
1817
|
+
return [
|
|
1818
|
+
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
|
|
1819
|
+
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
|
|
1820
|
+
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
|
|
1821
|
+
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
|
|
1822
|
+
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
|
|
1823
|
+
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
|
|
1824
|
+
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
|
|
1825
|
+
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
|
|
1826
|
+
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
|
|
1827
|
+
]
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def _mat_translate(tx, ty):
|
|
1831
|
+
return [1.0, 0.0, tx, 0.0, 1.0, ty, 0.0, 0.0, 1.0]
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _mat_scale(sx, sy):
|
|
1835
|
+
return [sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
def _mat_rotate(degrees):
|
|
1839
|
+
theta = math.radians(degrees)
|
|
1840
|
+
cos_t, sin_t = math.cos(theta), math.sin(theta)
|
|
1841
|
+
return [cos_t, -sin_t, 0.0, sin_t, cos_t, 0.0, 0.0, 0.0, 1.0]
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _mat_shear_h(skew_h_degrees):
|
|
1845
|
+
"""A pure horizontal shear: x' = x - tan(skew_h_degrees)*y, y' = y -- determinant exactly 1
|
|
1846
|
+
for any angle. Positive `skew_h_degrees` slants the top edge right (a point above center,
|
|
1847
|
+
smaller y, moves toward +x) -- the convention `gimp_transform_layer`'s own schema documents,
|
|
1848
|
+
and the one `ps_transform_layer`'s op=skew uses."""
|
|
1849
|
+
return [1.0, -math.tan(math.radians(skew_h_degrees)), 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def _mat_shear_v(skew_v_degrees):
|
|
1853
|
+
"""A pure vertical shear: x' = x, y' = -tan(skew_v_degrees)*x + y -- determinant exactly 1
|
|
1854
|
+
for any angle. Positive `skew_v_degrees` slants the left edge down (a point left of center,
|
|
1855
|
+
smaller x, moves toward +y)."""
|
|
1856
|
+
return [1.0, 0.0, 0.0, -math.tan(math.radians(skew_v_degrees)), 1.0, 0.0, 0.0, 0.0, 1.0]
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
def _mat_determinant(m):
|
|
1860
|
+
"""The determinant of `m`'s linear (non-translation) 2x2 part -- the translation column
|
|
1861
|
+
(indices 2, 5) never affects it. Zero means the transform collapses the rectangle to a line
|
|
1862
|
+
or a point; negative means it mirrors (flips handedness) rather than purely scaling/
|
|
1863
|
+
rotating/shearing it."""
|
|
1864
|
+
return m[0] * m[4] - m[1] * m[3]
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
# A composed matrix below this |determinant| is treated the same as an exact 0: numerically
|
|
1868
|
+
# indistinguishable from a transform that collapses the layer to a line, so refused outright
|
|
1869
|
+
# rather than handed to `Item.transform_matrix` to silently produce a degenerate result.
|
|
1870
|
+
TRANSFORM_LAYER_MIN_DETERMINANT = 1e-6
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def compose_layer_matrix(cx, cy, scale_x_percent, scale_y_percent, degrees,
|
|
1874
|
+
skew_h_degrees, skew_v_degrees, offset_x, offset_y):
|
|
1875
|
+
"""The 9 row-major coefficients for `Item.transform_matrix`: a scale (percent, 100 =
|
|
1876
|
+
unchanged), then a skew (skew_h_degrees/skew_v_degrees, composed as two independent real
|
|
1877
|
+
shears -- Sh_v . Sh_h, each with its OWN determinant of exactly 1, so the composed skew's
|
|
1878
|
+
determinant is always exactly 1 too, for any angle), then a rotation (degrees, clockwise),
|
|
1879
|
+
all anchored at the layer's own center (`cx`, `cy`, document pixels), followed by an absolute
|
|
1880
|
+
translation (`offset_x`, `offset_y`). Never the single combined-shear matrix
|
|
1881
|
+
[[1,-tan(h)],[-tan(v),1]] -- that matrix's OWN determinant is `1 - tan(h)*tan(v)`, which
|
|
1882
|
+
reaches exactly 0 at h=v=45 (collapsing the rectangle to a line) and goes NEGATIVE past that
|
|
1883
|
+
(silently mirroring it instead of shearing it). `op_transform_layer`'s `free` op calls this
|
|
1884
|
+
directly (scale + degrees + offset, no skew); `skew` calls it with scale 100/100, degrees 0,
|
|
1885
|
+
offset 0/0; `rotate`'s own bounds check (not its actual transform, which goes through
|
|
1886
|
+
`Item.transform_rotate` instead -- see ops.py) reuses it with scale 100/100, skew 0/0, offset
|
|
1887
|
+
0/0 purely to predict the post-rotation bounding box via `transformed_bounds`.
|
|
1888
|
+
|
|
1889
|
+
Raises if the FINAL composed matrix's determinant is below `TRANSFORM_LAYER_MIN_DETERMINANT`
|
|
1890
|
+
or negative -- scale alone is already bounded away from both by its own 1..10000% range, but
|
|
1891
|
+
this is the belt-and-braces check against the composed result as a whole, not just its own
|
|
1892
|
+
skew component."""
|
|
1893
|
+
m = _mat_translate(-cx, -cy)
|
|
1894
|
+
m = _mat_mul(_mat_scale(scale_x_percent / 100.0, scale_y_percent / 100.0), m)
|
|
1895
|
+
m = _mat_mul(_mat_shear_v(skew_v_degrees), _mat_mul(_mat_shear_h(skew_h_degrees), m))
|
|
1896
|
+
m = _mat_mul(_mat_rotate(degrees), m)
|
|
1897
|
+
m = _mat_mul(_mat_translate(cx, cy), m)
|
|
1898
|
+
m = _mat_mul(_mat_translate(offset_x, offset_y), m)
|
|
1899
|
+
det = _mat_determinant(m)
|
|
1900
|
+
if not math.isfinite(det) or det < 0 or abs(det) < TRANSFORM_LAYER_MIN_DETERMINANT:
|
|
1901
|
+
raise ValueError(
|
|
1902
|
+
'this combination of scale/skew/rotate collapses or mirrors the layer (determinant '
|
|
1903
|
+
'%.6g) instead of transforming it -- reduce the scale or skew and try again' % det
|
|
1904
|
+
)
|
|
1905
|
+
return m
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def transformed_bounds(matrix, x, y, width, height):
|
|
1909
|
+
"""The axis-aligned bounding box of a `width` x `height` rectangle at document-pixel origin
|
|
1910
|
+
(`x`, `y`), after mapping each of its four corners through `matrix` (9 row-major
|
|
1911
|
+
coefficients, `Item.transform_matrix`'s own layout) -- what the transformed layer's new
|
|
1912
|
+
offsets/width/height will measure to. Used to validate a rotate/skew/free transform against
|
|
1913
|
+
the same size cap `validate_resize_dims` enforces, BEFORE any pixel actually moves. Returns
|
|
1914
|
+
(new_x, new_y, new_width, new_height)."""
|
|
1915
|
+
corners = ((x, y), (x + width, y), (x, y + height), (x + width, y + height))
|
|
1916
|
+
xs = [matrix[0] * cx + matrix[1] * cy + matrix[2] for cx, cy in corners]
|
|
1917
|
+
ys = [matrix[3] * cx + matrix[4] * cy + matrix[5] for cx, cy in corners]
|
|
1918
|
+
min_x, max_x = min(xs), max(xs)
|
|
1919
|
+
min_y, max_y = min(ys), max(ys)
|
|
1920
|
+
return min_x, min_y, max_x - min_x, max_y - min_y
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def validate_transformed_layer_size(width, height):
|
|
1924
|
+
"""Refuses a scale/fit whose rounded target is under 1px in either dimension. Without this
|
|
1925
|
+
GIMP is handed a zero-size rectangle, returns nothing, and the caller reports a misleading
|
|
1926
|
+
"lock-position or lock-content" failure for a layer that was never locked."""
|
|
1927
|
+
if width < 1 or height < 1:
|
|
1928
|
+
raise ValueError(
|
|
1929
|
+
'the transform would shrink the layer to %dx%d px after rounding; use a larger scale'
|
|
1930
|
+
% (width, height)
|
|
1931
|
+
)
|
|
1932
|
+
|
|
1933
|
+
|
|
1361
1934
|
def region_to_proxy_px(region, scale):
|
|
1362
1935
|
"""Map a document-pixel `region` dict into proxy-pixel integer bounds at the proxy's
|
|
1363
1936
|
`scale` factor (0 < scale <= 1). Returns (x, y, width, height); width/height are at least
|
|
@@ -1370,6 +1943,13 @@ def region_to_proxy_px(region, scale):
|
|
|
1370
1943
|
return x, y, w, h
|
|
1371
1944
|
|
|
1372
1945
|
|
|
1946
|
+
def visible_image_ids(all_ids, hidden_ids):
|
|
1947
|
+
"""The ids in `all_ids` that are real documents: every id in `hidden_ids` (the bridge's own
|
|
1948
|
+
preview-proxy images, which are live GIMP images but not the user's) is dropped, order kept."""
|
|
1949
|
+
hidden = set(hidden_ids)
|
|
1950
|
+
return [i for i in all_ids if i not in hidden]
|
|
1951
|
+
|
|
1952
|
+
|
|
1373
1953
|
def read_pgm(raw):
|
|
1374
1954
|
"""Decode minimal binary PGM (P5, maxval 255) bytes. Returns (width, height, data).
|
|
1375
1955
|
|
|
@@ -1986,3 +2566,252 @@ def metadata_strip_settings(fmt, prop_names):
|
|
|
1986
2566
|
if fmt == 'tiff' and 'save-geotiff' in names:
|
|
1987
2567
|
settings.append('save-geotiff')
|
|
1988
2568
|
return settings
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
# ---- text layers (gimp_text) -----------------------------------------------------------------
|
|
2572
|
+
|
|
2573
|
+
TEXT_MAX_CHARS = 2000
|
|
2574
|
+
TEXT_MIN_FONT_PT = 1
|
|
2575
|
+
TEXT_MAX_FONT_PT = 1296
|
|
2576
|
+
TEXT_DEFAULT_FONT_PT = 24
|
|
2577
|
+
TEXT_FONT_LIST_CAP = 200
|
|
2578
|
+
FONT_SUGGESTION_LIMIT = 8
|
|
2579
|
+
DEFAULT_TEXT_FONT = 'Sans-serif'
|
|
2580
|
+
DEFAULT_RESOLUTION_PPI = 72.0
|
|
2581
|
+
|
|
2582
|
+
# gimp_text's `alignment` enum is ps_text's. GIMP's own justification has only left / right /
|
|
2583
|
+
# center / fill, so the three "last line" variants Photoshop adds are refused rather than
|
|
2584
|
+
# approximated.
|
|
2585
|
+
TEXT_ALIGNMENTS = {'LEFT': 'left', 'CENTER': 'center', 'RIGHT': 'right', 'FULLYJUSTIFIED': 'fill'}
|
|
2586
|
+
TEXT_UNSUPPORTED_ALIGNMENTS = ('LEFTJUSTIFIED', 'CENTERJUSTIFIED', 'RIGHTJUSTIFIED')
|
|
2587
|
+
_JUSTIFICATION_TO_ALIGNMENT = {nick: name for name, nick in TEXT_ALIGNMENTS.items()}
|
|
2588
|
+
|
|
2589
|
+
# Words that describe a face of a family rather than the family itself, used to tell "Arial" +
|
|
2590
|
+
# "Narrow Bold" (a face of the family) from an unrelated font whose name merely starts the same.
|
|
2591
|
+
_FONT_STYLE_WORDS = frozenset((
|
|
2592
|
+
'regular', 'book', 'roman', 'normal', 'medium', 'bold', 'italic', 'oblique', 'light', 'thin',
|
|
2593
|
+
'extralight', 'ultralight', 'semilight', 'semibold', 'demibold', 'extrabold', 'ultrabold',
|
|
2594
|
+
'black', 'heavy', 'condensed', 'semicondensed', 'extracondensed', 'narrow', 'cond', 'expanded',
|
|
2595
|
+
'semiexpanded', 'extra', 'ultra', 'semi', 'demi', 'variable',
|
|
2596
|
+
))
|
|
2597
|
+
_FONT_REGULAR_ORDER = ('regular', 'book', 'roman', 'normal', 'medium')
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
def validate_text_content(value):
|
|
2601
|
+
if not isinstance(value, str):
|
|
2602
|
+
raise ValueError('text must be a string')
|
|
2603
|
+
if value == '':
|
|
2604
|
+
raise ValueError('text must not be empty')
|
|
2605
|
+
if len(value) > TEXT_MAX_CHARS:
|
|
2606
|
+
raise ValueError('text must be at most %d characters (got %d)' % (TEXT_MAX_CHARS, len(value)))
|
|
2607
|
+
return value
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
def validate_font_size_pt(value):
|
|
2611
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
2612
|
+
raise ValueError('font_size must be a number')
|
|
2613
|
+
return validate_range('font_size', value, TEXT_MIN_FONT_PT, TEXT_MAX_FONT_PT)
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
def validate_text_rgb(args):
|
|
2617
|
+
"""(r, g, b) ints 0..255 from `red`/`green`/`blue`, or None when none of the three is given.
|
|
2618
|
+
Giving only some of them is refused rather than guessing the rest."""
|
|
2619
|
+
given = [k for k in ('red', 'green', 'blue') if args.get(k) is not None]
|
|
2620
|
+
if not given:
|
|
2621
|
+
return None
|
|
2622
|
+
if len(given) != 3:
|
|
2623
|
+
raise ValueError('red, green and blue must all be given together (missing: %s)'
|
|
2624
|
+
% ', '.join(k for k in ('red', 'green', 'blue') if k not in given))
|
|
2625
|
+
out = []
|
|
2626
|
+
for key in ('red', 'green', 'blue'):
|
|
2627
|
+
value = args[key]
|
|
2628
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or int(value) != value:
|
|
2629
|
+
raise ValueError('%s must be an integer 0..255' % key)
|
|
2630
|
+
out.append(validate_int_range(key, value, 0, 255))
|
|
2631
|
+
return tuple(out)
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def srgb_u8_to_linear(value):
|
|
2635
|
+
"""An 8-bit sRGB channel as the linear-light float GEGL's set_rgba expects."""
|
|
2636
|
+
c = value / 255.0
|
|
2637
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
|
+
def linear_to_srgb_u8(value):
|
|
2641
|
+
"""A linear-light float channel (GEGL's get_rgba) as an 8-bit sRGB integer."""
|
|
2642
|
+
v = min(1.0, max(0.0, value))
|
|
2643
|
+
c = v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
|
|
2644
|
+
return int(round(c * 255))
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
def justification_nick(alignment):
|
|
2648
|
+
"""GIMP's justification nick ('left' | 'right' | 'center' | 'fill') for a ps_text-style
|
|
2649
|
+
`alignment`; refuses the variants GIMP has no equivalent for, naming what is supported."""
|
|
2650
|
+
if alignment in TEXT_UNSUPPORTED_ALIGNMENTS:
|
|
2651
|
+
raise ValueError(
|
|
2652
|
+
'alignment %s is not supported by GIMP text layers; use one of %s'
|
|
2653
|
+
% (alignment, ', '.join(TEXT_ALIGNMENTS))
|
|
2654
|
+
)
|
|
2655
|
+
if alignment not in TEXT_ALIGNMENTS:
|
|
2656
|
+
raise ValueError('alignment must be one of %s'
|
|
2657
|
+
% ', '.join(list(TEXT_ALIGNMENTS) + list(TEXT_UNSUPPORTED_ALIGNMENTS)))
|
|
2658
|
+
return TEXT_ALIGNMENTS[alignment]
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
def alignment_name(nick):
|
|
2662
|
+
"""The ps_text-style alignment name for a GIMP justification nick (None when unknown)."""
|
|
2663
|
+
return _JUSTIFICATION_TO_ALIGNMENT.get(nick)
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
def _resolution_ppi(ppi):
|
|
2667
|
+
return float(ppi) if ppi and ppi > 0 else DEFAULT_RESOLUTION_PPI
|
|
2668
|
+
|
|
2669
|
+
|
|
2670
|
+
def pt_to_px(pt, ppi):
|
|
2671
|
+
"""Points -> pixels at the image's vertical resolution (1 pt = 1/72 in)."""
|
|
2672
|
+
return pt * _resolution_ppi(ppi) / 72.0
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
def px_to_pt(px, ppi):
|
|
2676
|
+
return round(px * 72.0 / _resolution_ppi(ppi), 2)
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def unit_size_to_pt(size, unit_is_pixel, units_per_inch, ppi):
|
|
2680
|
+
"""A text layer's font size in points, whichever unit it is stored in: pixels convert through
|
|
2681
|
+
the image resolution; any other unit through its own units-per-inch factor."""
|
|
2682
|
+
if unit_is_pixel or not units_per_inch:
|
|
2683
|
+
return px_to_pt(size, ppi)
|
|
2684
|
+
return round(size / units_per_inch * 72.0, 2)
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
def _font_tokens(name):
|
|
2688
|
+
return re.sub(r'[-_]+', ' ', name).lower().split()
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _font_key(name):
|
|
2692
|
+
return ' '.join(_font_tokens(name))
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def font_suggestions(query, names, limit=FONT_SUGGESTION_LIMIT):
|
|
2696
|
+
"""Up to `limit` installed font names closest to `query`: names containing it (or contained
|
|
2697
|
+
by it) first, then the nearest by spelling."""
|
|
2698
|
+
import difflib
|
|
2699
|
+
q = _font_key(query)
|
|
2700
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
2701
|
+
out = [n for n in ordered if q and (q in _font_key(n) or _font_key(n) in q)][:limit]
|
|
2702
|
+
if len(out) < limit:
|
|
2703
|
+
by_key = {}
|
|
2704
|
+
for n in ordered:
|
|
2705
|
+
by_key.setdefault(_font_key(n), n)
|
|
2706
|
+
for key in difflib.get_close_matches(q, list(by_key), n=limit, cutoff=0.0):
|
|
2707
|
+
if by_key[key] not in out:
|
|
2708
|
+
out.append(by_key[key])
|
|
2709
|
+
if len(out) >= limit:
|
|
2710
|
+
break
|
|
2711
|
+
return out[:limit]
|
|
2712
|
+
|
|
2713
|
+
|
|
2714
|
+
def resolve_font(query, names):
|
|
2715
|
+
"""Resolve a family ("Inter", "Open Sans") or full name ("Inter Bold") to an installed font
|
|
2716
|
+
name, case- and spacing-insensitively. Returns (name, matched_by) with matched_by in
|
|
2717
|
+
'name' | 'family+regular' | 'family'. Raises ValueError listing the closest installed names
|
|
2718
|
+
when nothing matches."""
|
|
2719
|
+
if not isinstance(query, str) or not query.strip():
|
|
2720
|
+
raise ValueError('font_name must be a non-empty string')
|
|
2721
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
2722
|
+
q = _font_key(query)
|
|
2723
|
+
for n in ordered:
|
|
2724
|
+
if _font_key(n) == q:
|
|
2725
|
+
return n, 'name'
|
|
2726
|
+
q_sorted = sorted(q.split())
|
|
2727
|
+
for n in ordered:
|
|
2728
|
+
if sorted(_font_tokens(n)) == q_sorted:
|
|
2729
|
+
return n, 'name'
|
|
2730
|
+
for style in _FONT_REGULAR_ORDER:
|
|
2731
|
+
wanted = q + ' ' + style
|
|
2732
|
+
for n in ordered:
|
|
2733
|
+
if _font_key(n) == wanted:
|
|
2734
|
+
return n, 'family+regular'
|
|
2735
|
+
family = [
|
|
2736
|
+
n for n in ordered
|
|
2737
|
+
if _font_key(n).startswith(q + ' ')
|
|
2738
|
+
and all(t in _FONT_STYLE_WORDS for t in _font_key(n)[len(q) + 1:].split())
|
|
2739
|
+
]
|
|
2740
|
+
if family:
|
|
2741
|
+
return min(family, key=lambda n: (len(n), n.lower())), 'family'
|
|
2742
|
+
suggestions = font_suggestions(query, ordered)
|
|
2743
|
+
if not suggestions:
|
|
2744
|
+
raise ValueError('no installed font matches %r (no fonts are installed)' % query[:100])
|
|
2745
|
+
raise ValueError(
|
|
2746
|
+
'no installed font matches %r. Closest installed names: %s' % (query[:100], ', '.join(suggestions))
|
|
2747
|
+
)
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
def pick_default_font(names):
|
|
2751
|
+
"""The font a text layer gets when none is named: GIMP's built-in generic sans if listed,
|
|
2752
|
+
else the first installed name (None when there are none)."""
|
|
2753
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
2754
|
+
for n in ordered:
|
|
2755
|
+
if _font_key(n) == _font_key(DEFAULT_TEXT_FONT):
|
|
2756
|
+
return n
|
|
2757
|
+
return ordered[0] if ordered else None
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def list_fonts(names, substring=None):
|
|
2761
|
+
"""(page, total) for gimp_inspect what=fonts: names sorted case-insensitively, filtered by a
|
|
2762
|
+
case-insensitive substring, capped at TEXT_FONT_LIST_CAP; `total` counts every match."""
|
|
2763
|
+
ordered = sorted(set(names), key=lambda n: n.lower())
|
|
2764
|
+
if substring:
|
|
2765
|
+
needle = substring.lower()
|
|
2766
|
+
ordered = [n for n in ordered if needle in n.lower()]
|
|
2767
|
+
return ordered[:TEXT_FONT_LIST_CAP], len(ordered)
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
# Headroom on the small-size layout probe: glyph advances hinted at a few pixels round to whole
|
|
2771
|
+
# pixels, so scaling a probe up can undershoot the full-size layout.
|
|
2772
|
+
TEXT_ESTIMATE_MARGIN = 1.3
|
|
2773
|
+
|
|
2774
|
+
|
|
2775
|
+
def estimate_text_extent(probe_w, probe_h, probe_px, size_px, chars=0, lines=1, letter_spacing=0.0,
|
|
2776
|
+
line_spacing=0.0, indent=0.0):
|
|
2777
|
+
"""The (width, height) in px a text layer will render at `size_px`, from its layout measured
|
|
2778
|
+
at `probe_px` (`probe_w` x `probe_h`), with headroom. Letter spacing, line spacing and indent
|
|
2779
|
+
are absolute pixels that do not scale with the font, so each adds its full worst case: spacing
|
|
2780
|
+
after every character and between every line, plus the indent. Never below 1x1."""
|
|
2781
|
+
scale = size_px / float(probe_px)
|
|
2782
|
+
width = math.ceil(probe_w * scale * TEXT_ESTIMATE_MARGIN) + chars * max(0.0, letter_spacing) + max(0.0, indent)
|
|
2783
|
+
height = math.ceil(probe_h * scale * TEXT_ESTIMATE_MARGIN) + max(0, lines - 1) * max(0.0, line_spacing)
|
|
2784
|
+
return max(1, int(math.ceil(width))), max(1, int(math.ceil(height)))
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def check_estimated_text_size(width, height):
|
|
2788
|
+
"""Refuses text whose estimated size (`estimate_text_extent`) is past the engine's size cap,
|
|
2789
|
+
before anything is drawn at full size."""
|
|
2790
|
+
try:
|
|
2791
|
+
validate_resize_dims(width, height)
|
|
2792
|
+
except ValueError as exc:
|
|
2793
|
+
raise ValueError(
|
|
2794
|
+
'the text would render as about %dx%d px, past the size limit (%s); use a smaller '
|
|
2795
|
+
'font_size or less text' % (width, height, exc)
|
|
2796
|
+
)
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
TEXT_REPORT_MAX_CHARS = 200
|
|
2800
|
+
|
|
2801
|
+
|
|
2802
|
+
def text_for_report(text):
|
|
2803
|
+
"""(text, length, truncated) for reporting a layer's text: at most TEXT_REPORT_MAX_CHARS,
|
|
2804
|
+
since a layer opened from a file can hold any amount."""
|
|
2805
|
+
text = text or ''
|
|
2806
|
+
return text[:TEXT_REPORT_MAX_CHARS], len(text), len(text) > TEXT_REPORT_MAX_CHARS
|
|
2807
|
+
|
|
2808
|
+
|
|
2809
|
+
def check_text_layer_size(width, height):
|
|
2810
|
+
"""Refuses a rendered text layer past the engine's size cap (`validate_resize_dims`)."""
|
|
2811
|
+
try:
|
|
2812
|
+
validate_resize_dims(width, height)
|
|
2813
|
+
except ValueError as exc:
|
|
2814
|
+
raise ValueError(
|
|
2815
|
+
'the text would render as a %dx%d px layer, past the size limit (%s); use a smaller '
|
|
2816
|
+
'font_size or less text' % (width, height, exc)
|
|
2817
|
+
)
|