editmamei 1.8.0 → 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.
- package/README.md +2 -2
- package/dist/backends/gimp/bridge/lib.py +957 -22
- package/dist/backends/gimp/bridge/ops.py +1074 -62
- 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/core/server.js +58 -13
- package/dist/core/tool-activity.js +2 -0
- package/dist/core/tool-groups.js +2 -0
- package/dist/core/tool-tiers.js +3 -1
- package/dist/delivery/client.js +3 -0
- package/dist/modules/gimp/index.js +4 -0
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/tools/document-tools.js +68 -12
- package/dist/tools/gimp-adjustment-tools.js +4 -3
- package/dist/tools/gimp-checkpoint-tools.js +7 -1
- package/dist/tools/gimp-core-tools.js +3 -3
- package/dist/tools/gimp-document-tools.js +96 -17
- package/dist/tools/gimp-effect-tools.js +54 -8
- package/dist/tools/gimp-fill-tools.js +256 -0
- package/dist/tools/gimp-filter-tools.js +4 -1
- package/dist/tools/gimp-inspect-tools.js +13 -5
- package/dist/tools/gimp-layer-tools.js +2 -0
- package/dist/tools/gimp-result-text.js +104 -0
- package/dist/tools/gimp-retouch-tools.js +238 -0
- package/dist/tools/gimp-text-tools.js +67 -7
- package/dist/tools/gimp-verify-tools.js +11 -2
- package/dist/utils/operation-timeouts.js +6 -2
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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()))
|
|
@@ -560,10 +566,22 @@ def _set_drop_shadow(cfg, params):
|
|
|
560
566
|
# offset/radius does not grow the layer or canvas (measured at the schema maxima and in
|
|
561
567
|
# combination: no size change), it just gets cut off at the edge like any other
|
|
562
568
|
# DrawableFilter here, so no separate cap against "ballooning" is needed.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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)
|
|
567
585
|
|
|
568
586
|
|
|
569
587
|
SETTERS = {
|
|
@@ -586,6 +604,7 @@ SETTERS = {
|
|
|
586
604
|
'gegl:focus-blur': _set_lens_blur,
|
|
587
605
|
'gegl:noise-rgb': _set_add_noise,
|
|
588
606
|
'gegl:dropshadow': _set_drop_shadow,
|
|
607
|
+
'gegl:bilateral-filter': _set_surface_blur,
|
|
589
608
|
}
|
|
590
609
|
|
|
591
610
|
|
|
@@ -934,7 +953,7 @@ def _filter_record(filters, layer, f):
|
|
|
934
953
|
rec = filters.get(f.get_name())
|
|
935
954
|
if rec and rec['operation'] == f.get_operation_name():
|
|
936
955
|
source = 'editmamei'
|
|
937
|
-
type_ =
|
|
956
|
+
type_ = lib.record_type(rec)
|
|
938
957
|
mask = rec['params'].get('mask')
|
|
939
958
|
params = lib.user_params(type_, rec['params'])
|
|
940
959
|
else:
|
|
@@ -989,7 +1008,17 @@ def op_effect(args):
|
|
|
989
1008
|
lib.reject_foreign_fields(effect_type, args)
|
|
990
1009
|
img = _image(args)
|
|
991
1010
|
operation = lib.EFFECT_OPERATIONS[effect_type]
|
|
992
|
-
|
|
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 {})
|
|
993
1022
|
params = builder(args, defaults)
|
|
994
1023
|
default_name = effect_type.replace('_', ' ').title()
|
|
995
1024
|
return _apply_filter(img, args, operation, params, default_name, type_=effect_type)
|
|
@@ -1015,7 +1044,7 @@ def _match_select_visible(dup, target):
|
|
|
1015
1044
|
mask = target.get_mask()
|
|
1016
1045
|
if mask is not None:
|
|
1017
1046
|
dup.select_item(Gimp.ChannelOps.INTERSECT, mask)
|
|
1018
|
-
|
|
1047
|
+
_harden_selection(dup)
|
|
1019
1048
|
|
|
1020
1049
|
|
|
1021
1050
|
def _match_render(img, layer, roi, previous_filter_indexes, mode):
|
|
@@ -1090,7 +1119,7 @@ def _match_measure_reference(img, layer, roi, previous_filter_indexes, channels,
|
|
|
1090
1119
|
visible = Gimp.Selection.save(dup)
|
|
1091
1120
|
Gimp.Selection.grow(dup, lib.match_scaled_px(opts['surround_px'], factor))
|
|
1092
1121
|
dup.select_item(Gimp.ChannelOps.SUBTRACT, visible)
|
|
1093
|
-
|
|
1122
|
+
_harden_selection(dup)
|
|
1094
1123
|
if Gimp.Selection.is_empty(dup):
|
|
1095
1124
|
raise ValueError(
|
|
1096
1125
|
'there is no canvas around the layer to sample (it fills the canvas); use '
|
|
@@ -1329,6 +1358,305 @@ def op_match_layer(args):
|
|
|
1329
1358
|
}
|
|
1330
1359
|
|
|
1331
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
|
+
|
|
1332
1660
|
def op_filter(args):
|
|
1333
1661
|
"""Stack management: `op` in list | set_visibility | delete. There is deliberately no
|
|
1334
1662
|
`reorder` -- `Gimp.DrawableFilter` exposes only delete/set_visible/update, and the PDB has no
|
|
@@ -1647,6 +1975,17 @@ def _refuse_if_masked_filters(img, op_name, before_hint='Rotate, flip or resize'
|
|
|
1647
1975
|
ROTATE_DEPENDENT_OPERATIONS = frozenset({'gegl:vignette', 'gegl:motion-blur-linear', 'gegl:dropshadow'})
|
|
1648
1976
|
|
|
1649
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
|
+
|
|
1650
1989
|
def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
|
|
1651
1990
|
if lib.is_right_angle_degrees(degrees):
|
|
1652
1991
|
return
|
|
@@ -1654,7 +1993,7 @@ def _refuse_if_non_right_angle_with_tracked_effects(img, degrees, op_name):
|
|
|
1654
1993
|
live_names = _live_filter_names(img)
|
|
1655
1994
|
tracked = sorted(
|
|
1656
1995
|
name for name, rec in filters.items()
|
|
1657
|
-
if name in live_names and rec
|
|
1996
|
+
if name in live_names and _rotation_sensitive(rec)
|
|
1658
1997
|
)
|
|
1659
1998
|
if tracked:
|
|
1660
1999
|
raise ValueError(
|
|
@@ -1689,18 +2028,21 @@ def _snapshot_effect_transform(img, op_name, transform_fn, layers=None):
|
|
|
1689
2028
|
filters, _unknown = _ledger_get(img)
|
|
1690
2029
|
if not filters:
|
|
1691
2030
|
return {}
|
|
1692
|
-
snapshot = {
|
|
2031
|
+
snapshot = {
|
|
2032
|
+
name: (rec['operation'], dict(rec['params']), lib.record_type(rec))
|
|
2033
|
+
for name, rec in filters.items()
|
|
2034
|
+
}
|
|
1693
2035
|
planned = {}
|
|
1694
2036
|
for layer in (layers if layers is not None else _all_layers(img)):
|
|
1695
2037
|
for f in layer.get_filters():
|
|
1696
2038
|
entry = snapshot.get(f.get_name())
|
|
1697
2039
|
if entry is None or entry[0] != f.get_operation_name():
|
|
1698
2040
|
continue
|
|
1699
|
-
operation, params = entry
|
|
1700
|
-
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))
|
|
1701
2043
|
if new_params == params:
|
|
1702
2044
|
continue
|
|
1703
|
-
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_)
|
|
1704
2046
|
planned[f.get_name()] = (operation, new_params)
|
|
1705
2047
|
return planned
|
|
1706
2048
|
|
|
@@ -1973,16 +2315,40 @@ def op_preview(args):
|
|
|
1973
2315
|
dup.delete()
|
|
1974
2316
|
|
|
1975
2317
|
|
|
1976
|
-
|
|
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."""
|
|
1977
2330
|
w, h = dup.get_width(), dup.get_height()
|
|
1978
2331
|
buf = layer.get_buffer()
|
|
1979
2332
|
rect = Gegl.Rectangle.new(0, 0, w, h)
|
|
1980
2333
|
rgb = buf.get(rect, 1.0, "R'G'B' u8", Gegl.AbyssPolicy.NONE) if set(channels) - {'luminance'} else None
|
|
1981
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)
|
|
1982
2346
|
out = {}
|
|
1983
2347
|
for ch in channels:
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|
|
1986
2352
|
|
|
1987
2353
|
|
|
1988
2354
|
def op_histogram(args):
|
|
@@ -1991,13 +2357,22 @@ def op_histogram(args):
|
|
|
1991
2357
|
filters). `region`: computed on the (mirrored) proxy when the region maps to at least
|
|
1992
2358
|
`lib.MIN_PROXY_REGION_PX` on a side there, else a full-res crop -- same "too few proxy
|
|
1993
2359
|
samples to mean anything" reasoning as `lib.MIN_PROXY_REGION_PX`'s own docstring; exact=True
|
|
1994
|
-
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."""
|
|
1995
2365
|
img = _image(args)
|
|
1996
2366
|
channels = args.get('channels') or list(lib.HIST_CHANNELS)
|
|
1997
2367
|
bad = [c for c in channels if c not in lib.HIST_CHANNELS]
|
|
1998
2368
|
if bad:
|
|
1999
2369
|
raise ValueError('channels must be drawn from %s, got %s' % (list(lib.HIST_CHANNELS), bad))
|
|
2000
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)
|
|
2001
2376
|
region = args.get('region')
|
|
2002
2377
|
if region:
|
|
2003
2378
|
lib.validate_region(region, img.get_width(), img.get_height())
|
|
@@ -2015,27 +2390,36 @@ def op_histogram(args):
|
|
|
2015
2390
|
ph = min(ph, dup.get_height() - py)
|
|
2016
2391
|
dup.crop(pw, ph, px, py)
|
|
2017
2392
|
layer = dup.flatten()
|
|
2018
|
-
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
|
|
2019
2398
|
finally:
|
|
2020
2399
|
dup.delete()
|
|
2021
|
-
return {'exact': False, 'width': w, 'height': h, 'pixels': w * h, 'channels': out,
|
|
2022
|
-
'unmirrored_filters': unmirrored}
|
|
2023
2400
|
region = dict(region) # fall through to the full-res path below
|
|
2024
2401
|
exact = True
|
|
2025
2402
|
|
|
2026
2403
|
unmirrored = []
|
|
2027
|
-
if region:
|
|
2028
|
-
dup, layer = _region_full_res(img, region)
|
|
2029
|
-
elif exact:
|
|
2030
|
-
dup, layer = _composite(img)
|
|
2031
|
-
else:
|
|
2404
|
+
if not region and not exact:
|
|
2032
2405
|
dup, unmirrored = _proxy_render(img, 1024)
|
|
2033
2406
|
layer = dup.get_layers()[0]
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
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}
|
|
2039
2423
|
if not region and not exact:
|
|
2040
2424
|
result['unmirrored_filters'] = unmirrored
|
|
2041
2425
|
return result
|
|
@@ -2079,8 +2463,8 @@ def op_compare(args):
|
|
|
2079
2463
|
h = min(ph, dup.get_height() - y)
|
|
2080
2464
|
dup.crop(w, h, x, y)
|
|
2081
2465
|
base_layer, after_layer = base.flatten(), after.flatten()
|
|
2082
|
-
_bw, _bh, before_stats = _channel_stats_from(base, base_layer, channels)
|
|
2083
|
-
_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)
|
|
2084
2468
|
deltas = {}
|
|
2085
2469
|
for ch in channels:
|
|
2086
2470
|
b, a = before_stats[ch], after_stats[ch]
|
|
@@ -2109,12 +2493,12 @@ def op_compare(args):
|
|
|
2109
2493
|
raise ValueError('regions mode needs both region_a and region_b')
|
|
2110
2494
|
dup_a, layer_a = _region_full_res(img, region_a)
|
|
2111
2495
|
try:
|
|
2112
|
-
_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)
|
|
2113
2497
|
finally:
|
|
2114
2498
|
dup_a.delete()
|
|
2115
2499
|
dup_b, layer_b = _region_full_res(img, region_b)
|
|
2116
2500
|
try:
|
|
2117
|
-
_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)
|
|
2118
2502
|
finally:
|
|
2119
2503
|
dup_b.delete()
|
|
2120
2504
|
return {'region_a': stats_a, 'region_b': stats_b, 'proxy': False}
|
|
@@ -2161,15 +2545,7 @@ def _export_stripped(dup, path, options=None):
|
|
|
2161
2545
|
raise ValueError(
|
|
2162
2546
|
'export path must end in .jpg, .jpeg, .png, .webp, .tif, or .tiff (got %r)' % ext
|
|
2163
2547
|
)
|
|
2164
|
-
if fmt in ('png', 'tiff')
|
|
2165
|
-
bit_depth = lib.validate_choice('bit_depth', options.get('bit_depth', 8), lib.BIT_DEPTHS)
|
|
2166
|
-
else:
|
|
2167
|
-
bit_depth = 8
|
|
2168
|
-
target_precision = (
|
|
2169
|
-
Gimp.Precision.U16_NON_LINEAR if bit_depth == 16 else Gimp.Precision.U8_NON_LINEAR
|
|
2170
|
-
)
|
|
2171
|
-
if dup.get_precision() != target_precision:
|
|
2172
|
-
dup.convert_precision(target_precision)
|
|
2548
|
+
_export_precision(dup, options if fmt in ('png', 'tiff') else {})
|
|
2173
2549
|
try:
|
|
2174
2550
|
dup.set_metadata(Gimp.Metadata.new())
|
|
2175
2551
|
except Exception:
|
|
@@ -2201,6 +2577,68 @@ def _export_stripped(dup, path, options=None):
|
|
|
2201
2577
|
cfg.set_property('image', dup)
|
|
2202
2578
|
cfg.set_property('file', Gio.File.new_for_path(path))
|
|
2203
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):
|
|
2204
2642
|
# A failed write does not raise: run() returns an EXECUTION_ERROR status with GIMP's message
|
|
2205
2643
|
# as the second value (verified live, GIMP 3.2.6, writing into a missing directory).
|
|
2206
2644
|
result = proc.run(cfg)
|
|
@@ -2215,18 +2653,96 @@ def _export_stripped(dup, path, options=None):
|
|
|
2215
2653
|
raise lib.OpError('gimp_op_failed', 'could not write %s: %s' % (name, detail))
|
|
2216
2654
|
|
|
2217
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
|
+
|
|
2218
2729
|
def op_export(args):
|
|
2219
|
-
"""Writes path by extension: .xcf saves the live document,
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
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.
|
|
2224
2739
|
|
|
2225
2740
|
Flat exports flatten a duplicate first and save that. Handing the live document to
|
|
2226
2741
|
file_save lets the exporter render the filters itself, which measured 9.3 s for a 24 MP
|
|
2227
2742
|
JPEG with three curves; flatten (3.0 s) + save of the flat copy (1.0 s) gives the same pixels."""
|
|
2228
2743
|
img = _image(args)
|
|
2229
2744
|
path = lib.require(args, 'path')
|
|
2745
|
+
keep_alpha = lib.validate_keep_alpha(args, path)
|
|
2230
2746
|
if path.lower().endswith('.xcf'):
|
|
2231
2747
|
# file_save reports a failed write by returning False, not by raising (verified live).
|
|
2232
2748
|
if not Gimp.file_save(Gimp.RunMode.NONINTERACTIVE, img, Gio.File.new_for_path(path), None):
|
|
@@ -2235,8 +2751,19 @@ def op_export(args):
|
|
|
2235
2751
|
'could not save %s (check that the folder exists and is writable)'
|
|
2236
2752
|
% os.path.basename(path),
|
|
2237
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)
|
|
2238
2757
|
else:
|
|
2239
|
-
|
|
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)
|
|
2240
2767
|
try:
|
|
2241
2768
|
_export_stripped(dup, path, args)
|
|
2242
2769
|
finally:
|
|
@@ -2244,6 +2771,85 @@ def op_export(args):
|
|
|
2244
2771
|
return {'path': path, 'bytes': os.path.getsize(path)}
|
|
2245
2772
|
|
|
2246
2773
|
|
|
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
|
+
|
|
2247
2853
|
def _replace_named_channel(img, name, w, h):
|
|
2248
2854
|
"""Remove any existing channel called `name` and insert a fresh, black-filled one of the
|
|
2249
2855
|
image's own size -- `op_create_mask`'s replace-by-name semantics. Restores the image's
|
|
@@ -2680,7 +3286,8 @@ def _resolve_parent_group(img, args):
|
|
|
2680
3286
|
parent_id = args.get('parent_group')
|
|
2681
3287
|
if parent_id is None:
|
|
2682
3288
|
return None
|
|
2683
|
-
|
|
3289
|
+
gid = lib.gimp_id(parent_id)
|
|
3290
|
+
parent = Gimp.Layer.get_by_id(gid) if gid is not None else None
|
|
2684
3291
|
owner = _owning_image(parent)
|
|
2685
3292
|
if parent is None or owner is None or owner.get_id() != img.get_id():
|
|
2686
3293
|
raise ValueError('no layer with id %s on image %s' % (parent_id, img.get_id()))
|
|
@@ -2785,7 +3392,7 @@ _LAYER_CAPABLE_BASE_TYPES = {
|
|
|
2785
3392
|
}
|
|
2786
3393
|
|
|
2787
3394
|
|
|
2788
|
-
def _layer_type_for(img):
|
|
3395
|
+
def _layer_type_for(img, tool='gimp_layer op=create'):
|
|
2789
3396
|
"""The `Gimp.ImageType` a new layer should use so it matches `img`'s own color model instead
|
|
2790
3397
|
of relying on GIMP's own silent coercion (verified live: an RGBA layer inserted into a
|
|
2791
3398
|
grayscale image is quietly converted to GRAYA on insert -- this asks for the right type up
|
|
@@ -2794,7 +3401,7 @@ def _layer_type_for(img):
|
|
|
2794
3401
|
layer_type = _LAYER_CAPABLE_BASE_TYPES.get(base)
|
|
2795
3402
|
if layer_type is None:
|
|
2796
3403
|
raise ValueError(
|
|
2797
|
-
'
|
|
3404
|
+
'%s does not support %s images -- only RGB and grayscale' % (tool, base.value_nick)
|
|
2798
3405
|
)
|
|
2799
3406
|
return layer_type
|
|
2800
3407
|
|
|
@@ -3285,6 +3892,171 @@ def _resolved_font(fonts, font_name):
|
|
|
3285
3892
|
return fonts[name]
|
|
3286
3893
|
|
|
3287
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
|
+
|
|
3288
4060
|
def _op_text_create(img, args):
|
|
3289
4061
|
text = lib.validate_text_content(lib.require(args, 'text'))
|
|
3290
4062
|
size_pt = lib.validate_font_size_pt(args.get('font_size', lib.TEXT_DEFAULT_FONT_PT))
|
|
@@ -3292,14 +4064,26 @@ def _op_text_create(img, args):
|
|
|
3292
4064
|
y = int(args.get('y', 100))
|
|
3293
4065
|
_validated_move_offset(img, x, y)
|
|
3294
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)
|
|
3295
4069
|
justification = None
|
|
3296
4070
|
if args.get('alignment') is not None:
|
|
3297
4071
|
justification = _JUSTIFICATIONS[lib.justification_nick(args['alignment'])]
|
|
3298
4072
|
_layer_type_for(img) # refuses image modes a text layer cannot be added to (indexed)
|
|
3299
4073
|
font = _resolved_font(_font_list(), args.get('font_name'))
|
|
3300
4074
|
size_px = lib.pt_to_px(size_pt, _image_ppi(img))
|
|
3301
|
-
|
|
3302
|
-
|
|
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())
|
|
3303
4087
|
if layer is None:
|
|
3304
4088
|
raise lib.OpError('gimp_op_failed', 'GIMP could not create the text layer')
|
|
3305
4089
|
try:
|
|
@@ -3310,9 +4094,17 @@ def _op_text_create(img, args):
|
|
|
3310
4094
|
_set_text_color(layer, rgb)
|
|
3311
4095
|
if justification is not None:
|
|
3312
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)
|
|
3313
4103
|
lib.check_text_layer_size(layer.get_width(), layer.get_height())
|
|
3314
4104
|
layer.set_offsets(x, y)
|
|
3315
4105
|
record = _text_record(layer)
|
|
4106
|
+
if box is not None:
|
|
4107
|
+
record.update(_text_box_fields(layer, extra))
|
|
3316
4108
|
except Exception:
|
|
3317
4109
|
_discard_layer(img, layer)
|
|
3318
4110
|
raise
|
|
@@ -3357,9 +4149,10 @@ def _op_text_edit(img, args, apply_fn, precheck_fn=None):
|
|
|
3357
4149
|
|
|
3358
4150
|
def _op_text_set_content(img, args):
|
|
3359
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.
|
|
3360
4153
|
return _op_text_edit(
|
|
3361
4154
|
img, args, lambda layer: layer.set_text(text),
|
|
3362
|
-
precheck_fn=lambda layer: _check_estimated_text_size(
|
|
4155
|
+
precheck_fn=lambda layer: None if _text_enforce_box(layer) else _check_estimated_text_size(
|
|
3363
4156
|
text, layer.get_font(), _text_size_px(layer), img, layer),
|
|
3364
4157
|
)
|
|
3365
4158
|
|
|
@@ -3371,6 +4164,8 @@ def _op_text_set_font(img, args):
|
|
|
3371
4164
|
new_size_px = lib.pt_to_px(size_pt, _image_ppi(img)) if size_pt is not None else None
|
|
3372
4165
|
|
|
3373
4166
|
def precheck_fn(layer):
|
|
4167
|
+
if _text_enforce_box(layer):
|
|
4168
|
+
return
|
|
3374
4169
|
size_px = new_size_px if new_size_px is not None else _text_size_px(layer)
|
|
3375
4170
|
_check_estimated_text_size(layer.get_text(), font, size_px, img, layer)
|
|
3376
4171
|
|
|
@@ -3399,12 +4194,85 @@ def _op_text_set_alignment(img, args):
|
|
|
3399
4194
|
return _op_text_edit(img, args, lambda layer: layer.set_justification(justification))
|
|
3400
4195
|
|
|
3401
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
|
+
|
|
3402
4269
|
TEXT_OPS = {
|
|
3403
4270
|
'create': _op_text_create,
|
|
3404
4271
|
'set_content': _op_text_set_content,
|
|
3405
4272
|
'set_font': _op_text_set_font,
|
|
3406
4273
|
'set_color': _op_text_set_color,
|
|
3407
4274
|
'set_alignment': _op_text_set_alignment,
|
|
4275
|
+
'set_box': _op_text_set_box,
|
|
3408
4276
|
}
|
|
3409
4277
|
|
|
3410
4278
|
|
|
@@ -3998,7 +4866,7 @@ def _ensure_layer_alpha(layer):
|
|
|
3998
4866
|
return True
|
|
3999
4867
|
|
|
4000
4868
|
|
|
4001
|
-
def _tracked_effect_names_on(img, layer):
|
|
4869
|
+
def _tracked_effect_names_on(img, layer, rotation_only=False):
|
|
4002
4870
|
"""Live, ledgered position/direction-dependent EFFECT filter names (vignette, motion_blur,
|
|
4003
4871
|
drop_shadow -- `ROTATE_DEPENDENT_OPERATIONS`) anywhere in `layer`'s own subtree (itself, or
|
|
4004
4872
|
every descendant for a group target) -- NOT its ancestors: an ancestor group's own filter is
|
|
@@ -4008,6 +4876,8 @@ def _tracked_effect_names_on(img, layer):
|
|
|
4008
4876
|
subtree = _layer_subtree(layer)
|
|
4009
4877
|
live_names = {f.get_name() for l in subtree for f in l.get_filters()}
|
|
4010
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, {})))
|
|
4011
4881
|
return sorted(
|
|
4012
4882
|
name for name in live_names
|
|
4013
4883
|
if filters.get(name, {}).get('operation') in ROTATE_DEPENDENT_OPERATIONS
|
|
@@ -4027,7 +4897,7 @@ def _refuse_if_tracked_effects_on(img, op_name, layer, reason):
|
|
|
4027
4897
|
def _refuse_if_non_right_angle_tracked_effects_on(img, degrees, op_name, layer):
|
|
4028
4898
|
if lib.is_right_angle_degrees(degrees):
|
|
4029
4899
|
return
|
|
4030
|
-
tracked = _tracked_effect_names_on(img, layer)
|
|
4900
|
+
tracked = _tracked_effect_names_on(img, layer, rotation_only=True)
|
|
4031
4901
|
if tracked:
|
|
4032
4902
|
raise ValueError(
|
|
4033
4903
|
'%s cannot use an arbitrary angle (%.4g°) while effect filter(s) %s are present on '
|
|
@@ -4397,6 +5267,29 @@ def _capture_selection(img, w, h, scratch):
|
|
|
4397
5267
|
scratch.update(0, 0, w, h)
|
|
4398
5268
|
|
|
4399
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
|
+
|
|
4400
5293
|
def _shrink_edge_locked(img, px):
|
|
4401
5294
|
"""`Gimp.Selection.shrink` that does not erode from the canvas edge. GIMP's own shrink treats
|
|
4402
5295
|
the area outside the canvas as unselected (its PDB call hardcodes edge-lock off), so a
|
|
@@ -4618,14 +5511,14 @@ def op_modify_mask(args):
|
|
|
4618
5511
|
elif how == 'invert':
|
|
4619
5512
|
Gimp.Selection.invert(img)
|
|
4620
5513
|
elif how == 'harden':
|
|
4621
|
-
|
|
5514
|
+
_harden_selection(img)
|
|
4622
5515
|
elif how == 'smooth':
|
|
4623
5516
|
# Round off jaggies: feather, then re-threshold at 50%. `px` is optional here (unlike
|
|
4624
5517
|
# the four ops above) -- a plain smoothing pass with no caller-chosen radius is still
|
|
4625
5518
|
# meaningful, so it floors at 1.0 rather than requiring one.
|
|
4626
5519
|
px = lib.validate_range('px', args.get('px', 0), 0, lib.MAX_FEATHER_PX)
|
|
4627
5520
|
Gimp.Selection.feather(img, max(px, 1.0))
|
|
4628
|
-
|
|
5521
|
+
_harden_selection(img)
|
|
4629
5522
|
else:
|
|
4630
5523
|
raise ValueError('unknown op %r' % how)
|
|
4631
5524
|
return _selection_to_channel(img, out)
|
|
@@ -4692,6 +5585,125 @@ def op_layer_mask(args):
|
|
|
4692
5585
|
'has_mask': layer.get_mask() is not None}
|
|
4693
5586
|
|
|
4694
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
|
+
|
|
4695
5707
|
def _gray_bytes_image(data, w, h):
|
|
4696
5708
|
"""A new GRAY image carrying `data` as its one layer's pixels. Caller deletes it. `tmp` is torn
|
|
4697
5709
|
down on any failure after its own creation (layer build, insert, or buffer write) -- otherwise
|
|
@@ -4899,7 +5911,7 @@ OPS = {
|
|
|
4899
5911
|
'select': op_select, 'modify_mask': op_modify_mask, 'layer_mask': op_layer_mask,
|
|
4900
5912
|
'mask_preview': op_mask_preview, 'load_mask': op_load_mask, 'render_layer': op_render_layer,
|
|
4901
5913
|
'transform_layer': op_transform_layer,
|
|
4902
|
-
'match_layer': op_match_layer,
|
|
5914
|
+
'match_layer': op_match_layer, 'retouch': op_retouch, 'fill': op_fill, 'render_region': op_render_region,
|
|
4903
5915
|
}
|
|
4904
5916
|
|
|
4905
5917
|
|