Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package Photini for openSUSE:Factory checked in at 2021-07-14 23:58:50 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/Photini (Old) and /work/SRC/openSUSE:Factory/.Photini.new.2625 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "Photini" Wed Jul 14 23:58:50 2021 rev:23 rq:906278 version:2021.7.0 Changes: -------- --- /work/SRC/openSUSE:Factory/Photini/Photini.changes 2021-06-16 20:37:07.839354622 +0200 +++ /work/SRC/openSUSE:Factory/.Photini.new.2625/Photini.changes 2021-07-14 23:59:16.593386797 +0200 @@ -1,0 +2,11 @@ +Mon Jul 12 10:54:42 UTC 2021 - Luigi Baldoni <aloi...@gmx.com> + +- Update to version 2021.7.0 + * Added a tab for 'ownership' and copyright details. + * Added 'tooltip' hints to some text fields. + * Try to ensure only one instance of Photini runs at a time. + * Added menu option to make thumbnails for all images that + have none. + * Other minor improvements and bug fixes. + +------------------------------------------------------------------- Old: ---- Photini-2021.6.0.tar.gz New: ---- Photini-2021.7.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ Photini.spec ++++++ --- /var/tmp/diff_new_pack.xrVMGc/_old 2021-07-14 23:59:17.205382316 +0200 +++ /var/tmp/diff_new_pack.xrVMGc/_new 2021-07-14 23:59:17.209382287 +0200 @@ -17,7 +17,7 @@ Name: Photini -Version: 2021.6.0 +Version: 2021.7.0 Release: 0 Summary: Digital photograph metadata (EXIF, IPTC, XMP) editing application License: GPL-3.0-or-later ++++++ Photini-2021.6.0.tar.gz -> Photini-2021.7.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/CHANGELOG.txt new/Photini-2021.7.0/CHANGELOG.txt --- old/Photini-2021.6.0/CHANGELOG.txt 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/CHANGELOG.txt 2021-07-12 11:49:32.000000000 +0200 @@ -16,6 +16,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. +Changes in v2021.7.0: + 1/ Added a tab for 'ownership' and copyright details. + 2/ Added 'tooltip' hints to some text fields. + 3/ Try to ensure only one instance of Photini runs at a time. + 4/ Added menu option to make thumbnails for all images that have none. + 5/ Other minor improvements and bug fixes. + Changes in v2021.6.0: 1/ Show IPTC-IIM data length limits in text fields. 2/ Drop use of Python FlickrAPI library. Binary files old/Photini-2021.6.0/src/doc/images/screenshot_200.png and new/Photini-2021.7.0/src/doc/images/screenshot_200.png differ Binary files old/Photini-2021.6.0/src/doc/images/screenshot_201.png and new/Photini-2021.7.0/src/doc/images/screenshot_201.png differ Binary files old/Photini-2021.6.0/src/doc/images/screenshot_202.png and new/Photini-2021.7.0/src/doc/images/screenshot_202.png differ Binary files old/Photini-2021.6.0/src/doc/images/screenshot_203.png and new/Photini-2021.7.0/src/doc/images/screenshot_203.png differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/index.rst new/Photini-2021.7.0/src/doc/index.rst --- old/Photini-2021.6.0/src/doc/index.rst 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/doc/index.rst 2021-07-12 11:49:32.000000000 +0200 @@ -1,5 +1,5 @@ .. This is part of the Photini documentation. - Copyright (C) 2012-19 Jim Easterbrook. + Copyright (C) 2012-21 Jim Easterbrook. See the file DOC_LICENSE.txt for copying conditions. Photini documentation @@ -20,7 +20,7 @@ misc/index other/reading -Copyright (C) 2012-19 Jim Easterbrook. +Copyright (C) 2012-21 Jim Easterbrook. Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/manual/descriptive_metadata.rst new/Photini-2021.7.0/src/doc/manual/descriptive_metadata.rst --- old/Photini-2021.6.0/src/doc/manual/descriptive_metadata.rst 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/doc/manual/descriptive_metadata.rst 2021-07-12 11:49:32.000000000 +0200 @@ -24,6 +24,7 @@ (Some software calls this field "author" or "byline".) Now Photini will ask for the name of the creator. Edit the name if required, then click ``OK``. +(More detailed ownership information can be added with the :doc:`ownership_metadata` tab.) .. |hazard| unicode:: U+026A1 @@ -76,3 +77,29 @@ This is stored in the metadata as a rating value of -1. .. image:: ../images/screenshot_14.png + +More information about the data fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Click on any field name below to see the IPTC definition and user notes for that field. +Although these fields are defined in an `IPTC standard`_, they are all stored in XMP metadata. +Most of them are also stored in "legacy" IPTC-IIM data. + +`Title / Object Name <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#title>`_ + IPTC ``Headline`` data, if present, is merged into this field. + Not stored in Exif. +`Description / Caption <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#description>`_ + The who, what and why of what the image depicts. +`Keywords <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#keywords>`_ + Separate words or phrases with ``;`` characters. Not stored in Exif. +`Rating <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#image-rating>`_ + Not stored in Exif or IPTC-IIM. +`Copyright <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#copyright-notice>`_ + Who owns the copyright. + This shows the same information as the :doc:`ownership_metadata` ``Copyright Notice`` field. +`Creator / Artist <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creator>`_ + Usually the photographer's name. + If there is more than one creator, separate them with a ``;`` character. + This shows the same information as the :doc:`ownership_metadata` ``Creator`` field. + +.. _IPTC standard: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/manual/image_selector.rst new/Photini-2021.7.0/src/doc/manual/image_selector.rst --- old/Photini-2021.6.0/src/doc/manual/image_selector.rst 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/doc/manual/image_selector.rst 2021-07-12 11:49:32.000000000 +0200 @@ -69,3 +69,15 @@ .. image:: ../images/screenshot_009.png The same menu items also appear in the main ``File`` menu. + +Using Photini with other programs +--------------------------------- + +If you use other applications that can display or edit image metadata then you need to be careful when using them with Photini. +Just like with a word processor or text editor it can be risky to have a file open for editing in more than one program. +If you make changes in Photini you should save them before getting another program to reload or reopen the file. +If you make changes in another program you should use the context menu described above to reload the file in Photini. + +You may also want to experiment with how other programs display the metadata you create in Photini and *vice versa*. +Be aware that other programs might not store their metadata in the picture files, but use a database or separate files (other than XMP sidecars). +Such programs are not compatible with Photini, unless they can be configured to use metadata standards. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/manual/index.rst new/Photini-2021.7.0/src/doc/manual/index.rst --- old/Photini-2021.6.0/src/doc/manual/index.rst 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/doc/manual/index.rst 2021-07-12 11:49:32.000000000 +0200 @@ -1,5 +1,5 @@ .. This is part of the Photini documentation. - Copyright (C) 2012-19 Jim Easterbrook. + Copyright (C) 2012-21 Jim Easterbrook. See the file ../DOC_LICENSE.txt for copying conditions. User manual @@ -13,6 +13,7 @@ image_selector descriptive_metadata + ownership_metadata technical_metadata map address diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/manual/ownership_metadata.rst new/Photini-2021.7.0/src/doc/manual/ownership_metadata.rst --- old/Photini-2021.6.0/src/doc/manual/ownership_metadata.rst 1970-01-01 01:00:00.000000000 +0100 +++ new/Photini-2021.7.0/src/doc/manual/ownership_metadata.rst 2021-07-12 11:49:32.000000000 +0200 @@ -0,0 +1,60 @@ +.. This is part of the Photini documentation. + Copyright (C) 2021 Jim Easterbrook. + See the file ../DOC_LICENSE.txt for copying condidions. + +Ownership metadata +================== + +The ``Ownership metadata`` tab (keyboard shortcut ``Alt+O``) allows you to edit ownership and copyright information about your photographs. + +.. image:: ../images/screenshot_200.png + +Most of this data will be the same for all your photographs, so Photini uses a "template" to apply the same text to all the selected images. +The ``Edit template`` button opens the dialog shown below. + +.. image:: ../images/screenshot_201.png + +Fill in any of the fields you want to use on every photograph. +The field labels are copied from the `IPTC standard`_, as is the help text which should pop up if you hover your mouse over a field. +If you have already set your copyright holder and creator names on the :doc:`descriptive_metadata` tab then this information should already be on the form. + +.. image:: ../images/screenshot_202.png + +Note that you can insert the year in which a photograph was taken with ``%Y``. +This is probably only useful in the ``Copyright Notice``, but is available for all fields. +(You can actually use any directive recognised by the `Python strftime function`_, such as ``%m`` for month number or ``%B`` for the month name.) + +.. image:: ../images/screenshot_203.png + +The ``Apply template`` button copies the template data to all the selected images, setting the correct year in the ``Copyright Notice``. +You can then add more information, or edit the existing information, in the usual way. + +More information about the data fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Click on any field name below to see the IPTC definition and user notes for that field. +Although these fields are defined in an `IPTC standard`_, they are all stored in XMP metadata. +Most of them are also stored in "legacy" IPTC-IIM data. + +`Creator <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creator>`_ + Usually the photographer's name. + If there is more than one creator, separate them with a ``;`` character. + This shows the same information as the :doc:`descriptive_metadata` ``Creator / Artist`` field. +`Creator's Jobtitle <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creators-jobtitle>`_ + If there is more than one creator, there should be a matching number of creator jobtitles, separated by ``;`` characters. +`Credit Line <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#credit-line>`_ + Usually the photographer's name, but could be their employer or client. +`Copyright Notice <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#copyright-notice>`_ + Who owns the copyright. + This shows the same information as the :doc:`descriptive_metadata` ``Copyright`` field. +`Rights Usage Terms <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#rights-usage-terms>`_ + Not stored in IPTC-IIM. +`Instructions <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#instructions>`_ + Notes to a publisher of the image. +`Contact Information <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creators-contact-info>`_ + Only the `Address <http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#address>`_ is stored in IPTC-IIM. + Multiple email addresses, URLs, or phone numbers should be separated by commas. + + +.. _IPTC standard: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata +.. _Python strftime function: https://docs.python.org/3.6/library/datetime.html#strftime-strptime-behavior diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/doc/manual/tags.rst new/Photini-2021.7.0/src/doc/manual/tags.rst --- old/Photini-2021.6.0/src/doc/manual/tags.rst 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/doc/manual/tags.rst 2021-07-12 11:49:32.000000000 +0200 @@ -18,52 +18,60 @@ These tags are where Photini stores its metadata. (Legacy IPTC-IIM data is only used if it already exists in the file, in line with the MWG guidelines, unless the "write unconditionally" user setting is enabled.) -Note that "Title / Object Name" and "Keywords" are not stored in Exif. +Note that some fields, such as "Title / Object Name" and "Keywords", are not stored in Exif. You may prefer not to use these fields to ensure compatibility with software that only handles Exif. -===================== ================================ ============================== ================== -Photini field Exif tag XMP tag IPTC-IIM tag -===================== ================================ ============================== ================== -Title / Object Name Xmp.dc.title Iptc.Application2.ObjectName -Description / Caption Exif.Image.ImageDescription Xmp.dc.description Iptc.Application2.Caption -Keywords Xmp.dc.subject Iptc.Application2.Keywords -Rating Xmp.xmp.Rating -Copyright Exif.Image.Copyright Xmp.dc.rights Iptc.Application2.Copyright -Creator / Artist Exif.Image.Artist Xmp.dc.creator Iptc.Application2.Byline -Date / time Taken Exif.Photo.DateTimeOriginal Xmp.photoshop.DateCreated Iptc.Application2.DateCreated - Exif.Photo.SubSecTimeOriginal Iptc.Application2.TimeCreated -Date / time Digitised Exif.Photo.DateTimeDigitized Xmp.xmp.CreateDate Iptc.Application2.DigitizationDate - Exif.Photo.SubSecTimeDigitized Iptc.Application2.DigitizationTime -Date / time Modified Exif.Image.DateTime Xmp.xmp.ModifyDate - Exif.Photo.SubSecTime -Orientation Exif.Image.Orientation -Camera maker name Exif.Image.Make -Camera model name Exif.Image.Model -Camera serial number Exif.Photo.BodySerialNumber -Lens maker name Exif.Photo.LensMake -Lens model name Exif.Photo.LensModel -Lens serial number Exif.Photo.LensSerialNumber -Lens specification Exif.Photo.LensSpecification -Focal length Exif.Photo.FocalLength -35mm equiv Exif.Photo.FocalLengthIn35mmFilm -Aperture Exif.Photo.FNumber -Latitude, longitude Exif.GPSInfo.GPSLatitude - Exif.GPSInfo.GPSLatitudeRef - Exif.GPSInfo.GPSLongitude - Exif.GPSInfo.GPSLongitudeRef -Altitude Exif.GPSInfo.GPSAltitude - Exif.GPSInfo.GPSAltitudeRef -Camera address Xmp.iptcExt.LocationCreated - Xmp.iptc.Location Iptc.Application2.SubLocation - Xmp.photoshop.City Iptc.Application2.City - Xmp.photoshop.State Iptc.Application2.ProvinceState - Xmp.photoshop.Country Iptc.Application2.CountryName - Xmp.iptc.CountryCode Iptc.Application2.CountryCode -Subject address Xmp.iptcExt.LocationShown -Thumbnail image Exif.Thumbnail.Compression - Exif.Thumbnail.ImageWidth - Exif.Thumbnail.ImageLength -===================== ================================ ============================== ================== +Some of the field names in the table below lingk to their definition in the IPTC standard. +You may find this useful when deciding what to write in those fields. + +======================== ================================ ============================== ================== +Photini field Exif tag XMP tag IPTC-IIM tag +======================== ================================ ============================== ================== +`Title / Object Name`_ Xmp.dc.title Iptc.Application2.ObjectName +`Description / Caption`_ Exif.Image.ImageDescription Xmp.dc.description Iptc.Application2.Caption +Keywords_ Xmp.dc.subject Iptc.Application2.Keywords +Rating_ Xmp.xmp.Rating +`Creator / Artist`_ Exif.Image.Artist Xmp.dc.creator Iptc.Application2.Byline +`Creator's Jobtitle`_ Xmp.photoshop.AuthorsPosition Iptc.Application2.BylineTitle +`Credit Line`_ Xmp.photoshop.Credit Iptc.Application2.Credit +`Copyright Notice`_ Exif.Image.Copyright Xmp.dc.rights Iptc.Application2.Copyright +`Rights Usage Terms`_ Xmp.xmpRights.UsageTerms +Instructions_ Xmp.photoshop.Instructions Iptc.Application2.SpecialInstructions +`Contact Information`_ Xmp.iptc.CreatorContactInfo Iptc.Application2.Contact +`Date / time Taken`_ Exif.Photo.DateTimeOriginal Xmp.photoshop.DateCreated Iptc.Application2.DateCreated + Exif.Photo.SubSecTimeOriginal Iptc.Application2.TimeCreated +Date / time Digitised Exif.Photo.DateTimeDigitized Xmp.xmp.CreateDate Iptc.Application2.DigitizationDate + Exif.Photo.SubSecTimeDigitized Iptc.Application2.DigitizationTime +Date / time Modified Exif.Image.DateTime Xmp.xmp.ModifyDate + Exif.Photo.SubSecTime +Orientation Exif.Image.Orientation +Camera maker name Exif.Image.Make +Camera model name Exif.Image.Model +Camera serial number Exif.Photo.BodySerialNumber +Lens maker name Exif.Photo.LensMake +Lens model name Exif.Photo.LensModel +Lens serial number Exif.Photo.LensSerialNumber +Lens specification Exif.Photo.LensSpecification +Focal length Exif.Photo.FocalLength +35mm equiv Exif.Photo.FocalLengthIn35mmFilm +Aperture Exif.Photo.FNumber +Latitude_, longitude_ Exif.GPSInfo.GPSLatitude + Exif.GPSInfo.GPSLatitudeRef + Exif.GPSInfo.GPSLongitude + Exif.GPSInfo.GPSLongitudeRef +Altitude_ Exif.GPSInfo.GPSAltitude + Exif.GPSInfo.GPSAltitudeRef +`Camera address`_ Xmp.iptcExt.LocationCreated + Xmp.iptc.Location Iptc.Application2.SubLocation + Xmp.photoshop.City Iptc.Application2.City + Xmp.photoshop.State Iptc.Application2.ProvinceState + Xmp.photoshop.Country Iptc.Application2.CountryName + Xmp.iptc.CountryCode Iptc.Application2.CountryCode +`Subject address`_ Xmp.iptcExt.LocationShown +Thumbnail image Exif.Thumbnail.Compression + Exif.Thumbnail.ImageWidth + Exif.Thumbnail.ImageLength +======================== ================================ ============================== ================== Secondary tags -------------- @@ -74,15 +82,15 @@ ===================== =============================== ============================== ================== Photini field Exif tag XMP tag IPTC-IIM tag ===================== =============================== ============================== ================== -Title / Object Name Exif.Image.XPTitle Iptc.Application2.Headline +Title / Object Name Exif.Image.XPTitle Xmp.photoshop.Headline Iptc.Application2.Headline Description / Caption Exif.Image.XPComment Xmp.tiff.ImageDescription Exif.Image.XPSubject Exif.Photo.UserComment Keywords Exif.Image.XPKeywords Rating Exif.Image.Rating Xmp.MicrosoftPhoto.Rating Exif.Image.RatingPercent -Copyright Xmp.tiff.Copyright Creator / Artist Exif.Image.XPAuthor Xmp.tiff.Artist +Copyright Xmp.tiff.Copyright Date / time Taken Exif.Image.DateTimeOriginal Xmp.exif.DateTimeOriginal Date / time Digitised Xmp.exif.DateTimeDigitized Date / time Modified Xmp.tiff.DateTime @@ -153,3 +161,21 @@ [1] The time zone offset is not directly presented to the user. It is applied to the Date / time Taken, Date / time Digitised and Date / time Modified fields if no other time zone information is available. + +.. _Altitude: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#gps-altitude +.. _Camera address: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#location-created +.. _Contact Information: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creators-contact-info +.. _Copyright Notice: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#copyright-notice +.. _Creator / Artist: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creator +.. _Creator's Jobtitle: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#creators-jobtitle +.. _Credit Line: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#credit-line +.. _Date / time Taken: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#date-created +.. _Description / Caption: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#description +.. _Instructions: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#instructions +.. _Keywords: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#keywords +.. _Latitude: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#gps-latitude +.. _longitude: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#gps-longitude +.. _Rating: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#image-rating +.. _Rights Usage Terms: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#rights-usage-terms +.. _Subject address: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#location-shown-in-the-image +.. _Title / Object Name: http://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#title diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/__init__.py new/Photini-2021.7.0/src/photini/__init__.py --- old/Photini-2021.6.0/src/photini/__init__.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/__init__.py 2021-07-12 11:49:32.000000000 +0200 @@ -1,4 +1,4 @@ from __future__ import unicode_literals -__version__ = '2021.6.0' -build = '1713 (b4a7af3)' +__version__ = '2021.7.0' +build = '1751 (9df716c)' diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/address.py new/Photini-2021.7.0/src/photini/address.py --- old/Photini-2021.6.0/src/photini/address.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/address.py 2021-07-12 11:49:32.000000000 +0200 @@ -49,6 +49,19 @@ length_check=ImageMetadata.max_bytes(key)) self.members[key].editingFinished.connect(self.editing_finished) self.members['CountryCode'].setMaximumWidth(40) + self.members['SubLocation'].setToolTip(translate( + 'AddressTab', 'Enter the name of the sublocation.')) + self.members['City'].setToolTip(translate( + 'AddressTab', 'Enter the name of the city.')) + self.members['ProvinceState'].setToolTip(translate( + 'AddressTab', 'Enter the name of the province or state.')) + self.members['CountryName'].setToolTip(translate( + 'AddressTab', 'Enter the name of the country.')) + self.members['CountryCode'].setToolTip(translate( + 'AddressTab', + 'Enter the 2 or 3 letter ISO 3166 country code of the country.')) + self.members['WorldRegion'].setToolTip(translate( + 'AddressTab', 'Enter the name of the world region.')) for j, text in enumerate(( translate('AddressTab', 'Street'), translate('AddressTab', 'City'), @@ -255,9 +268,14 @@ def set_tab_text(self, idx): if idx == 0: text = translate('AddressTab', 'camera') + tip = translate('AddressTab', 'Enter the details about a location' + ' where this image was created.') else: text = translate('AddressTab', 'subject {}').format(idx) + tip = translate('AddressTab', 'Enter the details about a location' + ' which is shown in this image.') self.location_info.setTabText(idx, text) + self.location_info.setTabToolTip(idx, tip) @QtSlot() @catch_all diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/descriptive.py new/Photini-2021.7.0/src/photini/descriptive.py --- old/Photini-2021.6.0/src/photini/descriptive.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/descriptive.py 2021-07-12 11:49:32.000000000 +0200 @@ -69,8 +69,10 @@ self.layout().addWidget(self.slider) # display self.display = QtWidgets.QLineEdit() + self.display.setStyleSheet("* {background-color:rgba(0,0,0,0);}") self.display.setFrame(False) self.display.setReadOnly(True) + self.display.setContextMenuPolicy(Qt.NoContextMenu) self.display.setFocusPolicy(Qt.NoFocus) self.layout().addWidget(self.display) # adopt child methods/signals @@ -200,6 +202,9 @@ self.widgets['title'] = SingleLineEdit( spell_check=True, length_check=ImageMetadata.max_bytes('title')) + self.widgets['title'].setToolTip(translate( + 'DescriptiveTab', 'Enter a short verbal and human readable name' + ' for the image, this may be the file name.')) self.widgets['title'].editingFinished.connect(self.new_title) self.form.addRow(translate( 'DescriptiveTab', 'Title / Object Name'), self.widgets['title']) @@ -207,13 +212,24 @@ self.widgets['description'] = MultiLineEdit( spell_check=True, length_check=ImageMetadata.max_bytes('description')) - self.widgets['description'].editingFinished.connect(self.new_description) - self.form.addRow(translate( - 'DescriptiveTab', 'Description / Caption'), self.widgets['description']) + self.widgets['description'].setToolTip(translate( + 'DescriptiveTab', 'Enter a "caption" describing the who, what,' + ' and why of what is happening in this image,\nthis might include' + ' names of people, and/or their role in the action that is taking' + ' place within the image.')) + self.widgets['description'].editingFinished.connect( + self.new_description) + self.form.addRow( + translate('DescriptiveTab', 'Description / Caption'), + self.widgets['description']) # keywords self.widgets['keywords'] = KeywordsEditor( spell_check=True, length_check=ImageMetadata.max_bytes('keywords'), multi_string=True) + self.widgets['keywords'].setToolTip(translate( + 'DescriptiveTab', 'Enter any number of keywords, terms or phrases' + ' used to express the subject matter in the image.' + '\nSeparate them with ";" characters.')) self.widgets['keywords'].editingFinished.connect(self.new_keywords) self.form.addRow(translate( 'DescriptiveTab', 'Keywords'), self.widgets['keywords']) @@ -226,6 +242,9 @@ # copyright self.widgets['copyright'] = LineEditWithAuto( length_check=ImageMetadata.max_bytes('copyright')) + self.widgets['copyright'].setToolTip(translate( + 'OwnerTab', 'Enter a notice on the current owner of the' + ' copyright for this image, such as "??2008 Jane Doe".')) self.widgets['copyright'].editingFinished.connect(self.new_copyright) self.widgets['copyright'].autoComplete.connect(self.auto_copyright) self.form.addRow(translate( @@ -233,6 +252,9 @@ # creator self.widgets['creator'] = LineEditWithAuto( length_check=ImageMetadata.max_bytes('creator'), multi_string=True) + self.widgets['creator'].setToolTip(translate( + 'OwnerTab', + 'Enter the name of the person that created this image.')) self.widgets['creator'].editingFinished.connect(self.new_creator) self.widgets['creator'].autoComplete.connect(self.auto_creator) self.form.addRow(translate( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/editor.py new/Photini-2021.7.0/src/photini/editor.py --- old/Photini-2021.6.0/src/photini/editor.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/editor.py 2021-07-12 11:49:32.000000000 +0200 @@ -38,8 +38,8 @@ from photini.loggerwindow import LoggerWindow from photini.opencage import OpenCage from photini.pyqt import ( - catch_all, Qt, QtCore, QtGui, QNetworkProxy, QtSlot, QtWidgets, qt_version, - width_for_text) + catch_all, Qt, QtCore, QtGui, QtNetwork, QNetworkProxy, QtSignal, QtSlot, + QtWidgets, qt_version, width_for_text) from photini.spelling import SpellCheck, spelling_version try: @@ -56,7 +56,7 @@ class QTabBar(QtWidgets.QTabBar): def tabSizeHint(self, index): size = super(QTabBar, self).tabSizeHint(index) - size.setWidth(max(size.width(), width_for_text(self, 'x' * 13))) + size.setWidth(max(size.width(), width_for_text(self, 'x' * 10))) return size @@ -84,6 +84,79 @@ super(ConfigStore, self).save() +class ServerSocket(QtCore.QObject): + new_files = QtSignal(list) + + def __init__(self, socket, *arg, **kw): + super(ServerSocket, self).__init__(*arg, **kw) + self.socket = socket + self.data = b'' + self.socket.setParent(self) + self.socket.readyRead.connect(self.read_data) + self.socket.disconnected.connect(self.deleteLater) + + @QtSlot() + @catch_all + def read_data(self): + file_list = [] + while self.socket.bytesAvailable(): + self.data += self.socket.readAll().data() + while b'\n' in self.data: + line, sep, self.data = self.data.partition(b'\n') + string = line.decode('utf-8') + file_list.append(string) + if file_list: + self.new_files.emit(file_list) + + +class InstanceServer(QtNetwork.QTcpServer): + new_files = QtSignal(list) + + def __init__(self, *arg, **kw): + super(InstanceServer, self).__init__(*arg, **kw) + config = BaseConfigStore('instance') + self.newConnection.connect(self.new_connection) + if not self.listen(QtNetwork.QHostAddress.LocalHost): + logger.error('Failed to start instance server:', self.errorString()) + return + config.set('server', 'port', self.serverPort()) + config.save() + + @QtSlot() + @catch_all + def new_connection(self): + window = self.parent().window() + window.raise_() + window.activateWindow() + while self.hasPendingConnections(): + socket = self.nextPendingConnection() + socket = ServerSocket(socket, parent=self) + socket.new_files.connect(self.new_files) + + +def SendToInstance(files): + config = BaseConfigStore('instance') + port = config.get('server', 'port') + if not port: + return False + socket = QtNetwork.QTcpSocket() + socket.connectToHost( + QtNetwork.QHostAddress.LocalHost, int(port), QtCore.QIODevice.WriteOnly) + if not socket.waitForConnected(1000): + logger.info('Connect to server: %s', socket.errorString()) + return False + for path in files: + data = os.path.abspath(path).encode('utf-8') + b'\n' + while data: + count = socket.write(data) + if count < 1: + logger.error('Write to server: %s', socket.errorString()) + break + data = data[count:] + socket.waitForBytesWritten() + socket.close() + return True + class MainWindow(QtWidgets.QMainWindow): def __init__(self, options, initial_files): super(MainWindow, self).__init__() @@ -117,7 +190,12 @@ # image selector self.image_list = ImageList() self.image_list.selection_changed.connect(self.new_selection) + self.image_list.image_list_changed.connect(self.new_image_list) self.image_list.new_metadata.connect(self.new_metadata) + # start instance server + instance_server = InstanceServer(parent=self) + instance_server.new_files.connect( + self.image_list.open_file_list, Qt.QueuedConnection) # update config file if self.app.config_store.config.has_section('tabs'): conv = { @@ -139,7 +217,8 @@ self.app.config_store.config.remove_option('tabs', key) # prepare list of tabs and associated stuff self.tab_list = [] - default_modules = ['photini.descriptive', 'photini.technical', + default_modules = ['photini.descriptive', 'photini.ownership', + 'photini.technical', 'photini.googlemap', 'photini.bingmap', 'photini.mapboxmap', 'photini.openstreetmap', 'photini.address', 'photini.flickr', @@ -172,6 +251,11 @@ self.save_action.setShortcuts(QtGui.QKeySequence.Save) self.save_action.setEnabled(False) self.save_action.triggered.connect(self.image_list.save_files) + self.fix_thumbs_action = file_menu.addAction( + translate('MenuBar', 'Fix missing thumbnails')) + self.fix_thumbs_action.setEnabled(False) + self.fix_thumbs_action.triggered.connect( + self.image_list.fix_missing_thumbs) action = file_menu.addAction(translate('MenuBar', 'Close all files')) action.triggered.connect(self.image_list.close_all_files) sep = file_menu.addAction(translate('MenuBar', 'Selected images')) @@ -389,6 +473,16 @@ self.import_gpx_action.setEnabled(len(selection) > 0) self.tabs.currentWidget().new_selection(selection) + @QtSlot() + @catch_all + def new_image_list(self): + for image in self.image_list.images: + thumb = image.metadata.thumbnail + if not thumb or not thumb['image']: + self.fix_thumbs_action.setEnabled(True) + return + self.fix_thumbs_action.setEnabled(False) + @QtSlot(bool) @catch_all def new_metadata(self, unsaved_data): @@ -451,6 +545,10 @@ '-v', '--verbose', action='count', default=0, help=translate('CLIHelp', 'increase number of logging messages')) options, args = parser.parse_args() + # if an instance of Photini is already running, send it the list of + # files to open + if SendToInstance(args): + return 0 # ensure warnings are visible in test mode if options.test: warnings.simplefilter('default') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/exiv2.py new/Photini-2021.7.0/src/photini/exiv2.py --- old/Photini-2021.6.0/src/photini/exiv2.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/exiv2.py 2021-07-12 11:49:32.000000000 +0200 @@ -48,9 +48,10 @@ # versions may not recognise them. The xapGImg URL is invalid, but # Photini doesn't write xapGImg so it doesn't matter. for prefix, name in ( - ('exifEX', 'http://cipa.jp/exif/1.0/'), - ('xapGImg', 'http://ns.adobe.com/xxx/'), - ('xmpGImg', 'http://ns.adobe.com/xap/1.0/g/img/')): + ('exifEX', 'http://cipa.jp/exif/1.0/'), + ('xapGImg', 'http://ns.adobe.com/xxx/'), + ('xmpGImg', 'http://ns.adobe.com/xap/1.0/g/img/'), + ('xmpRights', 'http://ns.adobe.com/xap/1.0/rights/')): GExiv2.Metadata.register_xmp_namespace(name, prefix) # Gexiv2 won't register the 'Iptc4xmpExt' namespace as its abbreviated @@ -325,8 +326,8 @@ def set_group(self, tag, value, idx=1): sub_tag = self._multi_tags[tag][0].format(idx=idx) - if any(value) and '/' in sub_tag: - # create XMP structure/container + if any(value) and '[' in sub_tag: + # create XMP array for t in self.get_xmp_tags(): if t.startswith(tag): # container already exists @@ -348,20 +349,24 @@ # maximum length of Iptc data _max_bytes = { - 'Iptc.Application2.Byline' : 32, - 'Iptc.Application2.Caption' : 2000, - 'Iptc.Application2.City' : 32, - 'Iptc.Application2.Copyright' : 128, - 'Iptc.Application2.CountryCode' : 3, - 'Iptc.Application2.CountryName' : 64, - 'Iptc.Application2.Headline' : 256, - 'Iptc.Application2.Keywords' : 64, - 'Iptc.Application2.ObjectName' : 64, - 'Iptc.Application2.Program' : 32, - 'Iptc.Application2.ProgramVersion' : 10, - 'Iptc.Application2.ProvinceState' : 32, - 'Iptc.Application2.SubLocation' : 32, - 'Iptc.Envelope.CharacterSet' : 32, + 'Iptc.Application2.Byline' : 32, + 'Iptc.Application2.BylineTitle' : 32, + 'Iptc.Application2.Caption' : 2000, + 'Iptc.Application2.City' : 32, + 'Iptc.Application2.Contact' : 128, + 'Iptc.Application2.Copyright' : 128, + 'Iptc.Application2.CountryCode' : 3, + 'Iptc.Application2.CountryName' : 64, + 'Iptc.Application2.Credit' : 32, + 'Iptc.Application2.Headline' : 256, + 'Iptc.Application2.Keywords' : 64, + 'Iptc.Application2.ObjectName' : 64, + 'Iptc.Application2.Program' : 32, + 'Iptc.Application2.ProgramVersion' : 10, + 'Iptc.Application2.ProvinceState' : 32, + 'Iptc.Application2.SpecialInstructions': 256, + 'Iptc.Application2.SubLocation' : 32, + 'Iptc.Envelope.CharacterSet' : 32, } @classmethod @@ -432,13 +437,23 @@ OK = True saved_tags = self.open_old(self._path).get_all_tags() for tag in self.get_all_tags(): - if tag in ('Exif.Image.GPSTag',): + if tag in saved_tags: + continue + if tag in ('Exif.Image.GPSTag', 'Exif.MakerNote.ByteOrder', + 'Exif.MakerNote.Offset', 'Exif.Photo.MakerNote'): # some tags disappear with good reason continue - if tag not in saved_tags: + family, group, tagname = tag.split('.') + if family == 'Exif' and group[:5] in ( + 'Canon', 'Casio', 'Fujif', 'Minol', 'Nikon', 'Olymp', + 'Panas', 'Penta', 'Samsu', 'Sigma', 'Sony1'): + # maker note tags are often not saved logger.warning( '%s: tag not saved: %s', os.path.basename(self._path), tag) - OK = False + continue + logger.error( + '%s: tag not saved: %s', os.path.basename(self._path), tag) + OK = False return OK def get_all_tags(self): @@ -505,6 +520,7 @@ 'Exif.{ifd}.FocalPlaneResolutionUnit'), 'Exif.{ifd}.ImageDimensions': ( 'Exif.{ifd}.ImageWidth', 'Exif.{ifd}.ImageLength'), + 'Iptc.Application2.Contact': ('Iptc.Application2.Contact',), 'Iptc.Application2.DateCreated': ( 'Iptc.Application2.DateCreated', 'Iptc.Application2.TimeCreated'), 'Iptc.Application2.DigitizationDate': ( @@ -531,6 +547,16 @@ 'Xmp.exifEX.LensMake': ( 'Xmp.exifEX.LensMake', 'Xmp.exifEX.LensModel', 'Xmp.exifEX.LensSerialNumber'), + 'Xmp.iptc.CreatorContactInfo': ( + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiAdrExtadr', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiAdrCity', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiAdrCtry', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiEmailWork', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiTelWork', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiAdrPcode', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiAdrRegion', + 'Xmp.iptc.CreatorContactInfo/Iptc4xmpCore:CiUrlWork', + ), 'Xmp.iptc.Location': ( 'Xmp.iptc.Location', 'Xmp.photoshop.City', 'Xmp.photoshop.State', 'Xmp.photoshop.Country', 'Xmp.iptc.CountryCode'), @@ -583,6 +609,8 @@ ('WN', 'Exif.OlympusEq.CameraType'), ('WN', 'Exif.Pentax.ModelID'), ('WN', 'Xmp.aux.SerialNumber')), + 'contact_info' : (('WA', 'Xmp.iptc.CreatorContactInfo'), + ('WA', 'Iptc.Application2.Contact')), 'copyright' : (('WA', 'Exif.Image.Copyright'), ('WA', 'Xmp.dc.rights'), ('W0', 'Xmp.tiff.Copyright'), @@ -592,6 +620,10 @@ ('WA', 'Xmp.dc.creator'), ('W0', 'Xmp.tiff.Artist'), ('WA', 'Iptc.Application2.Byline')), + 'creator_title' : (('WA', 'Xmp.photoshop.AuthorsPosition'), + ('WA', 'Iptc.Application2.BylineTitle')), + 'credit_line' : (('WA', 'Xmp.photoshop.Credit'), + ('WA', 'Iptc.Application2.Credit')), 'date_digitised' : (('WA', 'Exif.Photo.DateTimeDigitized'), ('WA', 'Xmp.xmp.CreateDate'), ('W0', 'Xmp.exif.DateTimeDigitized'), @@ -617,6 +649,8 @@ ('WX', 'Xmp.exif.FocalLength')), 'focal_length_35': (('WA', 'Exif.Photo.FocalLengthIn35mmFilm'), ('WX', 'Xmp.exif.FocalLengthIn35mmFilm')), + 'instructions' : (('WA', 'Xmp.photoshop.Instructions'), + ('WA', 'Iptc.Application2.SpecialInstructions')), 'keywords' : (('WA', 'Xmp.dc.subject'), ('WA', 'Iptc.Application2.Keywords'), ('W0', 'Exif.Image.XPKeywords')), @@ -666,9 +700,11 @@ ('WN', 'Exif.CanonTi.TimeZone'), ('WN', 'Exif.NikonWt.Timezone')), 'title' : (('WA', 'Xmp.dc.title'), + ('W0', 'Xmp.photoshop.Headline'), ('WA', 'Iptc.Application2.ObjectName'), ('W0', 'Exif.Image.XPTitle'), ('W0', 'Iptc.Application2.Headline')), + 'usageterms' : (('WA', 'Xmp.xmpRights.UsageTerms'),), } def read(self, name, type_): @@ -720,6 +756,25 @@ else: value.write(self, tag) + # Exiv2 uses the Exif.Image.Make value to decode Exif.Photo.MakerNote + # If we change Exif.Image.Make we should delete Exif.Photo.MakerNote + def camera_change_ok(self, camera_model): + if not (self.has_tag('Exif.Photo.MakerNote') + and self.has_tag('Exif.Image.Make')): + return True + if not camera_model: + return False + return self.get_string('Exif.Image.Make') == camera_model['make'] + + def delete_makernote(self, camera_model): + if self.camera_change_ok(camera_model): + return + self._clear_value('Exif.Image.Make') + self.save_file(self._path) + self.open_path(self._path) + self._clear_value('Exif.Photo.MakerNote') + self.save_file(self._path) + class ImageMetadata(Exiv2Metadata): _iptc_encodings = { @@ -826,25 +881,6 @@ if value: self.set_tag_multiple(tag, value) - # Exiv2 uses the Exif.Image.Make value to decode Exif.Photo.MakerNote - # If we change Exif.Image.Make we should delete Exif.Photo.MakerNote - def camera_change_ok(self, camera_model): - if not (self.has_tag('Exif.Photo.MakerNote') - and self.has_tag('Exif.Image.Make')): - return True - if not camera_model: - return False - return self.get_string('Exif.Image.Make') == camera_model['make'] - - def delete_makernote(self, camera_model): - if self.camera_change_ok(camera_model): - return - self._clear_value('Exif.Image.Make') - self.save_file(self._path) - self.open_path(self._path) - self._clear_value('Exif.Photo.MakerNote') - self.save_file(self._path) - class Preview(object): def __init__(self, md, buf): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/flickr.py new/Photini-2021.7.0/src/photini/flickr.py --- old/Photini-2021.6.0/src/photini/flickr.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/flickr.py 2021-07-12 11:49:32.000000000 +0200 @@ -498,11 +498,13 @@ 'description': image.metadata.description or '', } # keywords - params['tags'] = {'tags': 'uploaded:by=photini'} + params['tags'] = {'tags': 'uploaded:by=photini,'} for keyword in image.metadata.keywords or []: if not keyword.startswith(ID_TAG): - params['tags']['tags'] += ' "{}"'.format( - keyword.replace('"', '')) + keyword = keyword.replace('"', "'") + if ',' in keyword: + keyword = '"' + keyword + '"' + params['tags']['tags'] += keyword + ',' # date_taken date_taken = image.metadata.date_taken if date_taken: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/imagelist.py new/Photini-2021.7.0/src/photini/imagelist.py --- old/Photini-2021.6.0/src/photini/imagelist.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/imagelist.py 2021-07-12 11:49:32.000000000 +0200 @@ -109,25 +109,25 @@ def regenerate_thumbnail(self): # DCF spec says thumbnail must be 160 x 120, so other aspect # ratios are padded with black - # first try using FFmpeg to make thumbnail - data = self.make_thumb_ffmpeg() - if data: - self.metadata.thumbnail = {'data': data} - return True - # use PIL or Qt + # try using PIL first, good quality and quick qt_im = self.get_qt_image() - if not qt_im or qt_im.isNull(): - return False - if PIL: + if qt_im and PIL: data = self.make_thumb_PIL(qt_im) if data: self.metadata.thumbnail = {'data': data} return True - qt_im = self.make_thumb_Qt(qt_im) - if not qt_im or qt_im.isNull(): - return False - self.metadata.thumbnail = {'image': qt_im} - return True + # next try using FFmpeg, good quality but slower + data = self.make_thumb_ffmpeg() + if data: + self.metadata.thumbnail = {'data': data} + return True + # lastly use Qt, quick but not high quality + if qt_im: + qt_im = self.make_thumb_Qt(qt_im) + if qt_im: + self.metadata.thumbnail = {'image': qt_im} + return True + return False def make_thumb_ffmpeg(self): # get input dimensions @@ -167,8 +167,7 @@ reader.setAutoTransform(False) qt_im = reader.read() if not qt_im or qt_im.isNull(): - logger.error('Cannot read %s image data from %s', - self.file_type, self.path) + logger.error('Image read: %s: %s', self.path, reader.errorString()) return None w = qt_im.width() h = qt_im.height() @@ -183,21 +182,15 @@ if w >= h: new_h = int(0.5 + (float(w * 3) / 4.0)) new_w = int(0.5 + (float(h * 4) / 3.0)) - if new_h > h: - pad = (new_h - h) // 2 - qt_im = qt_im.copy(0, -pad, w, new_h) - elif new_w > w: - pad = (new_w - w) // 2 - qt_im = qt_im.copy(-pad, 0, new_w, h) else: new_h = int(0.5 + (float(w * 4) / 3.0)) new_w = int(0.5 + (float(h * 3) / 4.0)) - if new_w > w: - pad = (new_w - w) // 2 - qt_im = qt_im.copy(-pad, 0, new_w, h) - elif new_h > h: - pad = (new_h - h) // 2 - qt_im = qt_im.copy(0, -pad, w, new_h) + if new_w > w: + pad = (new_w - w) // 2 + qt_im = qt_im.copy(-pad, 0, new_w, h) + elif new_h > h: + pad = (new_h - h) // 2 + qt_im = qt_im.copy(0, -pad, w, new_h) return qt_im def make_thumb_PIL(self, qt_im): @@ -763,7 +756,8 @@ new_md = image.metadata old_md = Metadata(image.path, utf_safe=self.app.options.utf_safe) for key in ('title', 'description', 'keywords', 'rating', - 'copyright', 'creator', + 'creator', 'creator_title', 'credit_line', 'copyright', + 'usageterms', 'instructions', 'contact_info', 'date_taken', 'date_digitised', 'date_modified', 'orientation', 'camera_model', 'lens_model', 'lens_spec', @@ -817,6 +811,17 @@ @QtSlot() @catch_all + def fix_missing_thumbs(self): + with Busy(): + for image in self.get_images(): + thumb = image.metadata.thumbnail + if not thumb or not thumb['image']: + if image.regenerate_thumbnail(): + image.load_thumbnail() + self.image_list_changed.emit() + + @QtSlot() + @catch_all def close_selected_files(self): self.close_files(False) @@ -853,6 +858,7 @@ self._save_files(self.images) def _save_files(self, images=[]): + self._flush_editing() if_mode = eval(self.app.config_store.get('files', 'image', 'True')) sc_mode = self.app.config_store.get('files', 'sidecar', 'auto') force_iptc = eval( @@ -870,16 +876,13 @@ image.metadata.save( if_mode=if_mode, sc_mode=sc_mode, force_iptc=force_iptc, file_times=file_times) - unsaved = False - for image in self.images: - if image.metadata.changed(): - unsaved = True - break + unsaved = any([image.metadata.changed() for image in self.images]) self.new_metadata.emit(unsaved) def unsaved_files_dialog( self, all_files=False, with_cancel=True, with_discard=True): """Return true if OK to continue with close or quit or whatever""" + self._flush_editing() for image in self.images: if image.metadata.changed() and (all_files or image.selected): break @@ -903,6 +906,12 @@ return True return result == QtWidgets.QMessageBox.Discard + def _flush_editing(self): + # finish any editing in progress + current_focus = self.app.focusWidget() + if current_focus: + current_focus.clearFocus() + def get_selected_images(self): selection = [] for image in self.images: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/metadata.py new/Photini-2021.7.0/src/photini/metadata.py --- old/Photini-2021.6.0/src/photini/metadata.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/metadata.py 2021-07-12 11:49:32.000000000 +0200 @@ -195,6 +195,9 @@ @staticmethod def convert(value): + for key in value: + if isinstance(value[key], str): + value[key] = value[key].strip() or None return value def __setattr__(self, name, value): @@ -342,16 +345,32 @@ return this, False, True +class ContactInformation(MD_Dict_Mergeable): + # stores IPTC contact information + _keys = ('CiAdrExtadr', 'CiAdrCity', 'CiAdrCtry', 'CiEmailWork', + 'CiTelWork', 'CiAdrPcode', 'CiAdrRegion', 'CiUrlWork') + + def __str__(self): + result = [] + for key in self._keys: + if self[key]: + result.append('{}: {}'.format(key, self[key])) + return '\n'.join(result) + + @staticmethod + def merge_item(this, other): + if other in this: + return this, False, False + return this + ' // ' + other, True, False + + class Location(MD_Dict_Mergeable): # stores IPTC defined location heirarchy _keys = ('SubLocation', 'City', 'ProvinceState', 'CountryName', 'CountryCode', 'WorldRegion') - @staticmethod - def convert(value): - for key in value: - if value[key] and not value[key].strip(): - value[key] = None + def convert(self, value): + value = super(Location, self).convert(value) if value['CountryCode']: value['CountryCode'] = value['CountryCode'].upper() return value @@ -446,12 +465,8 @@ _keys = ('make', 'model', 'serial_no') _quiet = True - @staticmethod - def convert(value): - for key in value: - if value[key]: - value[key] = value[key].strip() - value[key] = value[key] or None + def convert(self, value): + value = super(CameraModel, self).convert(value) if value['model'] == 'unknown': value['model'] = None return value @@ -480,12 +495,8 @@ _keys = ('make', 'model', 'serial_no') _quiet = True - @staticmethod - def convert(value): - for key in value: - if value[key]: - value[key] = value[key].strip() - value[key] = value[key] or None + def convert(self, value): + value = super(LensModel, self).convert(value) if value['model'] == 'n/a': value['model'] = None if value['serial_no'] == '0000000000': @@ -551,6 +562,9 @@ @staticmethod def convert(value): + if value['data']: + # don't keep reference to what might be an entire image file + value['data'] = bytes(value['data']) if value['data'] and not value['image']: buf = QtCore.QBuffer() buf.setData(value['data']) @@ -561,14 +575,17 @@ if value['image'].isNull(): logger.error('thumbnail: %s', reader.errorString()) value['image'] = None - # don't keep reference to what might be an entire image file - value['data'] = None if value['image'] and not value['data']: buf = QtCore.QBuffer() buf.open(buf.WriteOnly) value['fmt'] = 'JPEG' - value['image'].save(buf, value['fmt']) - value['data'] = buf.data().data() + quality = 95 + while True: + value['image'].save(buf, value['fmt'], quality) + value['data'] = buf.data().data() + if len(value['data']) < 50000: + break + quality -= 1 if value['image']: value['w'] = value['image'].width() value['h'] = value['image'].height() @@ -618,7 +635,8 @@ handler.set_group(tag, [str(self['w']), str(self['h'])]) def __str__(self): - return '{fmt} thumbnail, {w}x{h}'.format(**self) + return '{fmt} thumbnail, {w}x{h}, {size} bytes'.format( + size=len(self['data']), **self) class DateTime(MD_Dict): @@ -778,11 +796,15 @@ date_string, time_string = file_value if not date_string: return None - # remove missing date values - while len(date_string) > 4 and date_string[-2:] == '00': - date_string = date_string[:-3] - if date_string == '0000': - return None + # remove missing date values, allowing for GIMP not writing + # leading zeros + parts = [int(x) for x in date_string.split('-')] + while parts[-1] == 0: + parts = parts[:-1] + if not parts: + return None + date_string = '-'.join(['{:04d}'.format(parts[0])] + + ['{:02d}'.format(x) for x in parts[1:]]) # ignore time if date is not full precision if len(date_string) < 10: time_string = None @@ -894,7 +916,10 @@ class MD_String(MD_Value, str): def __new__(cls, value): - return super(MD_String, cls).__new__(cls, value.strip()) + value = value.strip() + if not value: + return None + return super(MD_String, cls).__new__(cls, value) @classmethod def from_exiv2(cls, file_value, tag): @@ -1091,14 +1116,18 @@ 'altitude' : Altitude, 'aperture' : Aperture, 'camera_model' : CameraModel, + 'contact_info' : ContactInformation, 'copyright' : MD_String, 'creator' : MultiString, + 'creator_title' : MultiString, + 'credit_line' : MD_String, 'date_digitised' : DateTime, 'date_modified' : DateTime, 'date_taken' : DateTime, 'description' : MD_String, 'focal_length' : MD_Rational, 'focal_length_35': MD_Int, + 'instructions' : MD_String, 'keywords' : MultiString, 'latlong' : LatLon, 'lens_model' : LensModel, @@ -1113,6 +1142,7 @@ 'thumbnail' : Thumbnail, 'timezone' : Timezone, 'title' : MD_String, + 'usageterms' : MD_String, } def __init__(self, path, notify=None, utf_safe=False): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/ownership.py new/Photini-2021.7.0/src/photini/ownership.py --- old/Photini-2021.6.0/src/photini/ownership.py 1970-01-01 01:00:00.000000000 +0100 +++ new/Photini-2021.7.0/src/photini/ownership.py 2021-07-12 11:49:32.000000000 +0200 @@ -0,0 +1,387 @@ +## Photini - a simple photo metadata editor. +## http://github.com/jim-easterbrook/Photini +## Copyright (C) 2021 Jim Easterbrook j...@jim-easterbrook.me.uk +## +## This program is free software: you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation, either version 3 of the +## License, or (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program. If not, see +## <http://www.gnu.org/licenses/>. + +from __future__ import unicode_literals + +from datetime import datetime +import logging + +from photini.exiv2 import ImageMetadata +from photini.pyqt import ( + catch_all, MultiLineEdit, QtCore, QtSlot, QtWidgets, SingleLineEdit) + +logger = logging.getLogger(__name__) +translate = QtCore.QCoreApplication.translate + + +class TabWidget(QtWidgets.QWidget): + @staticmethod + def tab_name(): + return translate('OwnerTab', '&Ownership metadata') + + def __init__(self, image_list, *arg, **kw): + super(TabWidget, self).__init__(*arg, **kw) + self.config_store = QtWidgets.QApplication.instance().config_store + self.image_list = image_list + self.setLayout(QtWidgets.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + # construct widgets + self.enableable = [] + ## data fields + form, self.widgets = self.data_form() + self.enableable.append(form.widget()) + for key in self.widgets: + self.widgets[key].editingFinished.connect( + getattr(self, 'new_' + key)) + self.layout().addWidget(form) + ## buttons + buttons = QtWidgets.QVBoxLayout() + buttons.addStretch(1) + edit_template = QtWidgets.QPushButton( + translate('OwnerTab', 'Edit\ntemplate')) + edit_template.clicked.connect(self.edit_template) + buttons.addWidget(edit_template) + apply_template = QtWidgets.QPushButton( + translate('OwnerTab', 'Apply\ntemplate')) + apply_template.clicked.connect(self.apply_template) + self.enableable.append(apply_template) + buttons.addWidget(apply_template) + self.layout().addLayout(buttons) + # disable data entry until an image is selected + self.set_enabled(False) + + def data_form(self): + widgets = {} + scrollarea = QtWidgets.QScrollArea() + scrollarea.setFrameStyle(QtWidgets.QFrame.NoFrame) + scrollarea.setWidgetResizable(True) + form = QtWidgets.QWidget() + form.setLayout(QtWidgets.QFormLayout()) + # creator + widgets['creator'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('creator'), + spell_check=True, multi_string=True) + widgets['creator'].setToolTip(translate( + 'OwnerTab', + 'Enter the name of the person that created this image.')) + form.layout().addRow(translate( + 'OwnerTab', 'Creator'), widgets['creator']) + # creator title + widgets['creator_title'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('creator_title'), + spell_check=True, multi_string=True) + widgets['creator_title'].setToolTip(translate( + 'OwnerTab', + 'Enter the job title of the person listed in the Creator field.')) + form.layout().addRow(translate( + 'OwnerTab', "Creator's Jobtitle"), widgets['creator_title']) + # credit line + widgets['credit_line'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('credit_line'), + spell_check=True) + widgets['credit_line'].setToolTip(translate( + 'OwnerTab', + 'Enter who should be credited when this image is published.')) + form.layout().addRow(translate( + 'OwnerTab', 'Credit Line'), widgets['credit_line']) + # copyright + widgets['copyright'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('copyright'), spell_check=True) + widgets['copyright'].setToolTip(translate( + 'OwnerTab', 'Enter a notice on the current owner of the' + ' copyright for this image, such as "??2008 Jane Doe".')) + form.layout().addRow(translate( + 'OwnerTab', 'Copyright Notice'), widgets['copyright']) + # usage terms + widgets['usageterms'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('usageterms'), + spell_check=True) + widgets['usageterms'].setToolTip(translate( + 'OwnerTab', + 'Enter instructions on how this image can legally be used.')) + form.layout().addRow(translate( + 'OwnerTab', 'Rights Usage Terms'), widgets['usageterms']) + # special instructions + widgets['instructions'] = SingleLineEdit( + length_check=ImageMetadata.max_bytes('instructions'), + spell_check=True) + widgets['instructions'].setToolTip(translate( + 'OwnerTab', 'Enter information about embargoes, or other' + ' restrictions not covered by the Rights Usage Terms field.')) + form.layout().addRow(translate( + 'OwnerTab', 'Instructions'), widgets['instructions']) + ## contact information + contact_group = QtWidgets.QGroupBox() + contact_group.setLayout(QtWidgets.QFormLayout()) + # email addresses + widgets['CiEmailWork'] = SingleLineEdit() + widgets['CiEmailWork'].setToolTip(translate( + 'OwnerTab', 'Enter the work email address(es) for the person' + ' that created this image, such as n...@domain.com.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Email(s)'), widgets['CiEmailWork']) + # URLs + widgets['CiUrlWork'] = SingleLineEdit() + widgets['CiUrlWork'].setToolTip(translate( + 'OwnerTab', 'Enter the work Web URL(s) for the person' + ' that created this image, such as http://www.domain.com/.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Web URL(s)'), widgets['CiUrlWork']) + # phone numbers + widgets['CiTelWork'] = SingleLineEdit() + widgets['CiTelWork'].setToolTip(translate( + 'OwnerTab', 'Enter the work phone number(s) for the person' + ' that created this image, using the international format,' + ' such as +1 (123) 456789.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Phone(s)'), widgets['CiTelWork']) + # address + widgets['CiAdrExtadr'] = MultiLineEdit( + length_check=ImageMetadata.max_bytes('contact_info'), + spell_check=True) + widgets['CiAdrExtadr'].setToolTip(translate( + 'OwnerTab', + 'Enter address for the person that created this image.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Address'), widgets['CiAdrExtadr']) + # city + widgets['CiAdrCity'] = SingleLineEdit(spell_check=True) + widgets['CiAdrCity'].setToolTip(translate( + 'OwnerTab', 'Enter the city for the address of the person' + ' that created this image.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'City'), widgets['CiAdrCity']) + # postcode + widgets['CiAdrPcode'] = SingleLineEdit() + widgets['CiAdrPcode'].setToolTip(translate( + 'OwnerTab', 'Enter the postal code for the address of the person' + ' that created this image.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Postal Code'), widgets['CiAdrPcode']) + # region + widgets['CiAdrRegion'] = SingleLineEdit(spell_check=True) + widgets['CiAdrRegion'].setToolTip(translate( + 'OwnerTab', 'Enter the state for the address of the person' + ' that created this image.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'State/Province'), widgets['CiAdrRegion']) + # country + widgets['CiAdrCtry'] = SingleLineEdit(spell_check=True) + widgets['CiAdrCtry'].setToolTip(translate( + 'OwnerTab', 'Enter the country name for the address of the person' + ' that created this image.')) + contact_group.layout().addRow(translate( + 'OwnerTab', 'Country'), widgets['CiAdrCtry']) + form.layout().addRow(translate( + 'OwnerTab', 'Contact Information'), contact_group) + scrollarea.setWidget(form) + return scrollarea, widgets + + def set_enabled(self, enabled): + for widget in self.enableable: + widget.setEnabled(enabled) + + def refresh(self): + self.new_selection(self.image_list.get_selected_images()) + + def do_not_close(self): + return False + + @QtSlot() + @catch_all + def image_list_changed(self): + pass + + @QtSlot() + @catch_all + def new_creator(self): + self._new_value('creator') + + @QtSlot() + @catch_all + def new_creator_title(self): + self._new_value('creator_title') + + @QtSlot() + @catch_all + def new_credit_line(self): + self._new_value('credit_line') + + @QtSlot() + @catch_all + def new_copyright(self): + self._new_value('copyright') + + @QtSlot() + @catch_all + def new_usageterms(self): + self._new_value('usageterms') + + @QtSlot() + @catch_all + def new_instructions(self): + self._new_value('instructions') + + @QtSlot() + @catch_all + def new_CiEmailWork(self): + self._new_value('CiEmailWork') + + @QtSlot() + @catch_all + def new_CiUrlWork(self): + self._new_value('CiUrlWork') + + @QtSlot() + @catch_all + def new_CiTelWork(self): + self._new_value('CiTelWork') + + @QtSlot() + @catch_all + def new_CiAdrExtadr(self): + self._new_value('CiAdrExtadr') + + @QtSlot() + @catch_all + def new_CiAdrCity(self): + self._new_value('CiAdrCity') + + @QtSlot() + @catch_all + def new_CiAdrPcode(self): + self._new_value('CiAdrPcode') + + @QtSlot() + @catch_all + def new_CiAdrRegion(self): + self._new_value('CiAdrRegion') + + @QtSlot() + @catch_all + def new_CiAdrCtry(self): + self._new_value('CiAdrCtry') + + def _new_value(self, key): + images = self.image_list.get_selected_images() + if not self.widgets[key].is_multiple(): + value = self.widgets[key].get_value() + for image in images: + self._set_value(image, key, value) + self._update_widget(key, images) + + def _update_widget(self, key, images): + if not images: + return + values = [] + for image in images: + value = self._get_value(image, key) + if value not in values: + values.append(value) + if len(values) > 1: + self.widgets[key].set_multiple(choices=filter(None, values)) + else: + self.widgets[key].set_value(values[0]) + + @QtSlot() + @catch_all + def edit_template(self): + dialog = QtWidgets.QDialog(parent=self) + dialog.setFixedSize(min(800, self.window().width()), + min(600, self.window().height())) + dialog.setWindowTitle(self.tr('Photini: ownership template')) + dialog.setLayout(QtWidgets.QVBoxLayout()) + # main dialog area + form, widgets = self.data_form() + widgets['copyright'].setToolTip( + widgets['copyright'].toolTip() + ' ' + + translate('OwnerTab', + 'Use %Y to insert the year the photograph was taken.')) + for key in widgets: + value = None + if key == 'copyright': + name = self.config_store.get('user', 'copyright_name') or '' + text = (self.config_store.get('user', 'copyright_text') or + translate('DescriptiveTab', 'Copyright ??{year} {name}.' + ' All rights reserved.')) + value = text.format(year='%Y', name=name) + elif key == 'creator': + value = self.config_store.get('user', 'creator_name') + widgets[key].set_value( + self.config_store.get('ownership', key, value)) + dialog.layout().addWidget(form) + # apply & cancel buttons + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + dialog.layout().addWidget(button_box) + if dialog.exec_() != QtWidgets.QDialog.Accepted: + return + for key in widgets: + value = widgets[key].get_value() + if value: + self.config_store.set('ownership', key, value) + + @QtSlot() + @catch_all + def apply_template(self): + value = {} + for key in self.widgets: + text = self.config_store.get('ownership', key) + if text: + value[key] = text + images = self.image_list.get_selected_images() + for image in images: + date_taken = image.metadata.date_taken + if date_taken is None: + date_taken = datetime.now() + else: + date_taken = date_taken['datetime'] + for key in value: + self._set_value(image, key, date_taken.strftime(value[key])) + for key in value: + self._update_widget(key, images) + + def _set_value(self, image, key, value): + if key.startswith('Ci'): + info = dict(image.metadata.contact_info or {}) + info[key] = value + image.metadata.contact_info = info + else: + setattr(image.metadata, key, value) + + def _get_value(self, image, key): + if key.startswith('Ci'): + info = image.metadata.contact_info + if info: + return info[key] + return None + return getattr(image.metadata, key) + + @QtSlot(list) + @catch_all + def new_selection(self, selection): + if not selection: + for key in self.widgets: + self.widgets[key].set_value(None) + self.set_enabled(False) + return + for key in self.widgets: + self._update_widget(key, selection) + self.set_enabled(True) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Photini-2021.6.0/src/photini/pyqt.py new/Photini-2021.7.0/src/photini/pyqt.py --- old/Photini-2021.6.0/src/photini/pyqt.py 2021-06-16 13:18:18.000000000 +0200 +++ new/Photini-2021.7.0/src/photini/pyqt.py 2021-07-12 11:49:32.000000000 +0200 @@ -68,7 +68,7 @@ pass else: using_qtwebengine = eval(using_qtwebengine) - from PySide2 import QtCore, QtGui, QtWidgets + from PySide2 import QtCore, QtGui, QtNetwork, QtWidgets from PySide2.QtCore import Qt from PySide2.QtNetwork import QNetworkProxy if using_qtwebengine: @@ -91,7 +91,7 @@ pass else: using_qtwebengine = eval(using_qtwebengine) - from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5 import QtCore, QtGui, QtNetwork, QtWidgets from PyQt5.QtCore import Qt from PyQt5.QtNetwork import QNetworkProxy if using_qtwebengine: