Skip to content

Commit 1b639a0

Browse files
gh-118225: Support more options for copying images in Tkinter (GH-118228)
* Add the PhotoImage method copy_replace() to copy a region from one image to other image, possibly with pixel zooming and/or subsampling. * Add from_coords parameter to PhotoImage methods copy(), zoom() and subsample(). * Add zoom and subsample parameters to PhotoImage method copy().
1 parent 09871c9 commit 1b639a0

File tree

5 files changed

+268
-17
lines changed

5 files changed

+268
-17
lines changed

Doc/library/tkinter.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,15 @@ of :class:`tkinter.Image`:
979979
Either type of image is created through either the ``file`` or the ``data``
980980
option (other options are available as well).
981981

982+
.. versionchanged:: 3.13
983+
Added the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
984+
from one image to other image, possibly with pixel zooming and/or
985+
subsampling.
986+
Add *from_coords* parameter to :class:`!PhotoImage` methods :meth:`!copy()`,
987+
:meth:`!zoom()` and :meth:`!subsample()`.
988+
Add *zoom* and *subsample* parameters to :class:`!PhotoImage` method
989+
:meth:`!copy()`.
990+
982991
The image object can then be used wherever an ``image`` option is supported by
983992
some widget (e.g. labels, buttons, menus). In these cases, Tk will not keep a
984993
reference to the image. When the last Python reference to the image object is

Doc/whatsnew/3.13.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,15 @@ tkinter
882882
* Add the :meth:`!after_info` method for Tkinter widgets.
883883
(Contributed by Cheryl Sabella in :gh:`77020`.)
884884

885+
* Add the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
886+
from one image to other image, possibly with pixel zooming and/or
887+
subsampling.
888+
Add *from_coords* parameter to :class:`!PhotoImage` methods :meth:`!copy()`,
889+
:meth:`!zoom()` and :meth:`!subsample()`.
890+
Add *zoom* and *subsample* parameters to :class:`!PhotoImage` method
891+
:meth:`!copy()`.
892+
(Contributed by Serhiy Storchaka in :gh:`118225`.)
893+
885894
traceback
886895
---------
887896

Lib/test/test_tkinter/test_images.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,37 @@ def test_copy(self):
302302
image2 = image.copy()
303303
self.assertEqual(image2.width(), 16)
304304
self.assertEqual(image2.height(), 16)
305-
self.assertEqual(image.get(4, 6), image.get(4, 6))
305+
self.assertEqual(image2.get(4, 6), image.get(4, 6))
306+
307+
image2 = image.copy(from_coords=(2, 3, 14, 11))
308+
self.assertEqual(image2.width(), 12)
309+
self.assertEqual(image2.height(), 8)
310+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
311+
self.assertEqual(image2.get(11, 7), image.get(13, 10))
312+
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))
313+
314+
image2 = image.copy(from_coords=(2, 3, 14, 11), zoom=2)
315+
self.assertEqual(image2.width(), 24)
316+
self.assertEqual(image2.height(), 16)
317+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
318+
self.assertEqual(image2.get(23, 15), image.get(13, 10))
319+
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
320+
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))
321+
322+
image2 = image.copy(from_coords=(2, 3, 14, 11), subsample=2)
323+
self.assertEqual(image2.width(), 6)
324+
self.assertEqual(image2.height(), 4)
325+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
326+
self.assertEqual(image2.get(5, 3), image.get(12, 9))
327+
self.assertEqual(image2.get(3, 2), image.get(3*2+2, 2*2+3))
328+
329+
image2 = image.copy(from_coords=(2, 3, 14, 11), subsample=2, zoom=3)
330+
self.assertEqual(image2.width(), 18)
331+
self.assertEqual(image2.height(), 12)
332+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
333+
self.assertEqual(image2.get(17, 11), image.get(12, 9))
334+
self.assertEqual(image2.get(1*3, 2*3), image.get(1*2+2, 2*2+3))
335+
self.assertEqual(image2.get(1*3+2, 2*3+2), image.get(1*2+2, 2*2+3))
306336

307337
def test_subsample(self):
308338
image = self.create()
@@ -316,6 +346,13 @@ def test_subsample(self):
316346
self.assertEqual(image2.height(), 8)
317347
self.assertEqual(image2.get(2, 3), image.get(4, 6))
318348

349+
image2 = image.subsample(2, from_coords=(2, 3, 14, 11))
350+
self.assertEqual(image2.width(), 6)
351+
self.assertEqual(image2.height(), 4)
352+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
353+
self.assertEqual(image2.get(5, 3), image.get(12, 9))
354+
self.assertEqual(image2.get(1, 2), image.get(1*2+2, 2*2+3))
355+
319356
def test_zoom(self):
320357
image = self.create()
321358
image2 = image.zoom(2, 3)
@@ -330,6 +367,118 @@ def test_zoom(self):
330367
self.assertEqual(image2.get(8, 12), image.get(4, 6))
331368
self.assertEqual(image2.get(9, 13), image.get(4, 6))
332369

370+
image2 = image.zoom(2, from_coords=(2, 3, 14, 11))
371+
self.assertEqual(image2.width(), 24)
372+
self.assertEqual(image2.height(), 16)
373+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
374+
self.assertEqual(image2.get(23, 15), image.get(13, 10))
375+
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
376+
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))
377+
378+
def test_copy_replace(self):
379+
image = self.create()
380+
image2 = tkinter.PhotoImage(master=self.root)
381+
image2.copy_replace(image)
382+
self.assertEqual(image2.width(), 16)
383+
self.assertEqual(image2.height(), 16)
384+
self.assertEqual(image2.get(4, 6), image.get(4, 6))
385+
386+
image2 = tkinter.PhotoImage(master=self.root)
387+
image2.copy_replace(image, from_coords=(2, 3, 14, 11))
388+
self.assertEqual(image2.width(), 12)
389+
self.assertEqual(image2.height(), 8)
390+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
391+
self.assertEqual(image2.get(11, 7), image.get(13, 10))
392+
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))
393+
394+
image2 = tkinter.PhotoImage(master=self.root)
395+
image2.copy_replace(image)
396+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), shrink=True)
397+
self.assertEqual(image2.width(), 12)
398+
self.assertEqual(image2.height(), 8)
399+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
400+
self.assertEqual(image2.get(11, 7), image.get(13, 10))
401+
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))
402+
403+
image2 = tkinter.PhotoImage(master=self.root)
404+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), to=(3, 6))
405+
self.assertEqual(image2.width(), 15)
406+
self.assertEqual(image2.height(), 14)
407+
self.assertEqual(image2.get(0+3, 0+6), image.get(2, 3))
408+
self.assertEqual(image2.get(11+3, 7+6), image.get(13, 10))
409+
self.assertEqual(image2.get(2+3, 4+6), image.get(2+2, 4+3))
410+
411+
image2 = tkinter.PhotoImage(master=self.root)
412+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), to=(0, 0, 100, 50))
413+
self.assertEqual(image2.width(), 100)
414+
self.assertEqual(image2.height(), 50)
415+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
416+
self.assertEqual(image2.get(11, 7), image.get(13, 10))
417+
self.assertEqual(image2.get(2, 4), image.get(2+2, 4+3))
418+
self.assertEqual(image2.get(2+12, 4+8), image.get(2+2, 4+3))
419+
self.assertEqual(image2.get(2+12*2, 4), image.get(2+2, 4+3))
420+
self.assertEqual(image2.get(2, 4+8*3), image.get(2+2, 4+3))
421+
422+
image2 = tkinter.PhotoImage(master=self.root)
423+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), zoom=2)
424+
self.assertEqual(image2.width(), 24)
425+
self.assertEqual(image2.height(), 16)
426+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
427+
self.assertEqual(image2.get(23, 15), image.get(13, 10))
428+
self.assertEqual(image2.get(2*2, 4*2), image.get(2+2, 4+3))
429+
self.assertEqual(image2.get(2*2+1, 4*2+1), image.get(6+2, 2+3))
430+
431+
image2 = tkinter.PhotoImage(master=self.root)
432+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), subsample=2)
433+
self.assertEqual(image2.width(), 6)
434+
self.assertEqual(image2.height(), 4)
435+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
436+
self.assertEqual(image2.get(5, 3), image.get(12, 9))
437+
self.assertEqual(image2.get(1, 2), image.get(1*2+2, 2*2+3))
438+
439+
image2 = tkinter.PhotoImage(master=self.root)
440+
image2.copy_replace(image, from_coords=(2, 3, 14, 11), subsample=2, zoom=3)
441+
self.assertEqual(image2.width(), 18)
442+
self.assertEqual(image2.height(), 12)
443+
self.assertEqual(image2.get(0, 0), image.get(2, 3))
444+
self.assertEqual(image2.get(17, 11), image.get(12, 9))
445+
self.assertEqual(image2.get(3*3, 2*3), image.get(3*2+2, 2*2+3))
446+
self.assertEqual(image2.get(3*3+2, 2*3+2), image.get(3*2+2, 2*2+3))
447+
self.assertEqual(image2.get(1*3, 2*3), image.get(1*2+2, 2*2+3))
448+
self.assertEqual(image2.get(1*3+2, 2*3+2), image.get(1*2+2, 2*2+3))
449+
450+
def checkImgTrans(self, image, expected):
451+
actual = {(x, y)
452+
for x in range(image.width())
453+
for y in range(image.height())
454+
if image.transparency_get(x, y)}
455+
self.assertEqual(actual, expected)
456+
457+
def test_copy_replace_compositingrule(self):
458+
image1 = tkinter.PhotoImage(master=self.root, width=2, height=2)
459+
image1.blank()
460+
image1.put('black', to=(0, 0, 2, 2))
461+
image1.transparency_set(0, 0, True)
462+
463+
# default compositingrule
464+
image2 = tkinter.PhotoImage(master=self.root, width=3, height=3)
465+
image2.blank()
466+
image2.put('white', to=(0, 0, 2, 2))
467+
image2.copy_replace(image1, to=(1, 1))
468+
self.checkImgTrans(image2, {(0, 2), (2, 0)})
469+
470+
image3 = tkinter.PhotoImage(master=self.root, width=3, height=3)
471+
image3.blank()
472+
image3.put('white', to=(0, 0, 2, 2))
473+
image3.copy_replace(image1, to=(1, 1), compositingrule='overlay')
474+
self.checkImgTrans(image3, {(0, 2), (2, 0)})
475+
476+
image4 = tkinter.PhotoImage(master=self.root, width=3, height=3)
477+
image4.blank()
478+
image4.put('white', to=(0, 0, 2, 2))
479+
image4.copy_replace(image1, to=(1, 1), compositingrule='set')
480+
self.checkImgTrans(image4, {(0, 2), (1, 1), (2, 0)})
481+
333482
def test_put(self):
334483
image = self.create()
335484
image.put('{red green} {blue yellow}', to=(4, 6))

Lib/tkinter/__init__.py

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4278,33 +4278,112 @@ def cget(self, option):
42784278

42794279
def __getitem__(self, key):
42804280
return self.tk.call(self.name, 'cget', '-' + key)
4281-
# XXX copy -from, -to, ...?
42824281

4283-
def copy(self):
4284-
"""Return a new PhotoImage with the same image as this widget."""
4282+
def copy(self, *, from_coords=None, zoom=None, subsample=None):
4283+
"""Return a new PhotoImage with the same image as this widget.
4284+
4285+
The FROM_COORDS option specifies a rectangular sub-region of the
4286+
source image to be copied. It must be a tuple or a list of 1 to 4
4287+
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
4288+
opposite corners of the rectangle. If x2 and y2 are not specified,
4289+
the default value is the bottom-right corner of the source image.
4290+
The pixels copied will include the left and top edges of the
4291+
specified rectangle but not the bottom or right edges. If the
4292+
FROM_COORDS option is not given, the default is the whole source
4293+
image.
4294+
4295+
If SUBSAMPLE or ZOOM are specified, the image is transformed as in
4296+
the subsample() or zoom() methods. The value must be a single
4297+
integer or a pair of integers.
4298+
"""
42854299
destImage = PhotoImage(master=self.tk)
4286-
self.tk.call(destImage, 'copy', self.name)
4300+
destImage.copy_replace(self, from_coords=from_coords,
4301+
zoom=zoom, subsample=subsample)
42874302
return destImage
42884303

4289-
def zoom(self, x, y=''):
4304+
def zoom(self, x, y='', *, from_coords=None):
42904305
"""Return a new PhotoImage with the same image as this widget
4291-
but zoom it with a factor of x in the X direction and y in the Y
4292-
direction. If y is not given, the default value is the same as x.
4306+
but zoom it with a factor of X in the X direction and Y in the Y
4307+
direction. If Y is not given, the default value is the same as X.
4308+
4309+
The FROM_COORDS option specifies a rectangular sub-region of the
4310+
source image to be copied, as in the copy() method.
42934311
"""
4294-
destImage = PhotoImage(master=self.tk)
42954312
if y=='': y=x
4296-
self.tk.call(destImage, 'copy', self.name, '-zoom',x,y)
4297-
return destImage
4313+
return self.copy(zoom=(x, y), from_coords=from_coords)
42984314

4299-
def subsample(self, x, y=''):
4315+
def subsample(self, x, y='', *, from_coords=None):
43004316
"""Return a new PhotoImage based on the same image as this widget
4301-
but use only every Xth or Yth pixel. If y is not given, the
4302-
default value is the same as x.
4317+
but use only every Xth or Yth pixel. If Y is not given, the
4318+
default value is the same as X.
4319+
4320+
The FROM_COORDS option specifies a rectangular sub-region of the
4321+
source image to be copied, as in the copy() method.
43034322
"""
4304-
destImage = PhotoImage(master=self.tk)
43054323
if y=='': y=x
4306-
self.tk.call(destImage, 'copy', self.name, '-subsample',x,y)
4307-
return destImage
4324+
return self.copy(subsample=(x, y), from_coords=from_coords)
4325+
4326+
def copy_replace(self, sourceImage, *, from_coords=None, to=None, shrink=False,
4327+
zoom=None, subsample=None, compositingrule=None):
4328+
"""Copy a region from the source image (which must be a PhotoImage) to
4329+
this image, possibly with pixel zooming and/or subsampling. If no
4330+
options are specified, this command copies the whole of the source
4331+
image into this image, starting at coordinates (0, 0).
4332+
4333+
The FROM_COORDS option specifies a rectangular sub-region of the
4334+
source image to be copied. It must be a tuple or a list of 1 to 4
4335+
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
4336+
opposite corners of the rectangle. If x2 and y2 are not specified,
4337+
the default value is the bottom-right corner of the source image.
4338+
The pixels copied will include the left and top edges of the
4339+
specified rectangle but not the bottom or right edges. If the
4340+
FROM_COORDS option is not given, the default is the whole source
4341+
image.
4342+
4343+
The TO option specifies a rectangular sub-region of the destination
4344+
image to be affected. It must be a tuple or a list of 1 to 4
4345+
integers (x1, y1, x2, y2). (x1, y1) and (x2, y2) specify diagonally
4346+
opposite corners of the rectangle. If x2 and y2 are not specified,
4347+
the default value is (x1,y1) plus the size of the source region
4348+
(after subsampling and zooming, if specified). If x2 and y2 are
4349+
specified, the source region will be replicated if necessary to fill
4350+
the destination region in a tiled fashion.
4351+
4352+
If SHRINK is true, the size of the destination image should be
4353+
reduced, if necessary, so that the region being copied into is at
4354+
the bottom-right corner of the image.
4355+
4356+
If SUBSAMPLE or ZOOM are specified, the image is transformed as in
4357+
the subsample() or zoom() methods. The value must be a single
4358+
integer or a pair of integers.
4359+
4360+
The COMPOSITINGRULE option specifies how transparent pixels in the
4361+
source image are combined with the destination image. When a
4362+
compositing rule of 'overlay' is set, the old contents of the
4363+
destination image are visible, as if the source image were printed
4364+
on a piece of transparent film and placed over the top of the
4365+
destination. When a compositing rule of 'set' is set, the old
4366+
contents of the destination image are discarded and the source image
4367+
is used as-is. The default compositing rule is 'overlay'.
4368+
"""
4369+
options = []
4370+
if from_coords is not None:
4371+
options.extend(('-from', *from_coords))
4372+
if to is not None:
4373+
options.extend(('-to', *to))
4374+
if shrink:
4375+
options.append('-shrink')
4376+
if zoom is not None:
4377+
if not isinstance(zoom, (tuple, list)):
4378+
zoom = (zoom,)
4379+
options.extend(('-zoom', *zoom))
4380+
if subsample is not None:
4381+
if not isinstance(subsample, (tuple, list)):
4382+
subsample = (subsample,)
4383+
options.extend(('-subsample', *subsample))
4384+
if compositingrule:
4385+
options.extend(('-compositingrule', compositingrule))
4386+
self.tk.call(self.name, 'copy', sourceImage, *options)
43084387

43094388
def get(self, x, y):
43104389
"""Return the color (red, green, blue) of the pixel at X,Y."""
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add the :class:`!PhotoImage` method :meth:`!copy_replace` to copy a region
2+
from one image to other image, possibly with pixel zooming and/or
3+
subsampling. Add *from_coords* parameter to :class:`!PhotoImage` methods
4+
:meth:`!copy()`, :meth:`!zoom()` and :meth:`!subsample()`. Add *zoom* and
5+
*subsample* parameters to :class:`!PhotoImage` method :meth:`!copy()`.

0 commit comments

Comments
 (0)