Compare commits
2 Commits
8b2334a41f
...
b184c96697
Author | SHA1 | Date | |
---|---|---|---|
b184c96697 | |||
826b8e6635 |
24
COPYING
Normal file
24
COPYING
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <http://unlicense.org/>
|
50
README.md
50
README.md
@ -5,19 +5,16 @@ Minimalistic EXIF Editor.
|
|||||||
|
|
||||||
MEE allows you to get, remove and change EXIF tags in images.
|
MEE allows you to get, remove and change EXIF tags in images.
|
||||||
|
|
||||||
In addition to this, support for custom tags is implemented here. Custom tags are written in JSON format to the UserComment tag. MEE can operate with them in the same way as with ordinary tags. Be aware of the 64 kilobytes limit for the UserComment tag.
|
In addition to this, support for custom tags is implemented here. Custom tags are written in JSON format to the UserComment tag ([why](https://stackoverflow.com/questions/10833928/custom-exif-tags)). MEE can operate with them in the same way as with ordinary tags. Be aware of the 64 kilobytes limit for the UserComment tag.
|
||||||
|
|
||||||
Custom tags entered there can only be interpreted manually or with MEE.
|
Custom tags entered there can only be interpreted manually or with MEE.
|
||||||
|
|
||||||
Roadmap:
|
EXIF libraries support:
|
||||||
|
|
||||||
- [ ] Implement Mee as Python class
|
- [x] exif (only JPEG)
|
||||||
|
- [ ] piexif (JPEG, WebP, TIFF)
|
||||||
- EXIF libraries support
|
- [ ] pillow (JPEG, WebP, TIFF, PNG)
|
||||||
- [x] exif (only JPEG)
|
- [ ] Wrap Perl exiftool?
|
||||||
- [ ] piexif (JPEG, WebP, TIFF)
|
|
||||||
- [ ] pillow (JPEG, WebP, TIFF, PNG)
|
|
||||||
- [ ] Wrap Perl exiftool?
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
============
|
============
|
||||||
@ -39,20 +36,41 @@ Commands overview:
|
|||||||
cget, cset, cdel operate with JSON in UserComment EXIF tag.
|
cget, cset, cdel operate with JSON in UserComment EXIF tag.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mee ls <file>
|
mee ls [options] <file>
|
||||||
mee get [-k <key>] <file>
|
mee get [options] [-k <key>] <file>
|
||||||
mee set -k <key> -v <value> <file> [-o <file>]
|
mee set [options] -k <key> -v <value> <file> [-o <file>]
|
||||||
mee del -k <key> <file> [-o <file>]
|
mee del [options] (--all | -k <key>) <file> [-o <file>]
|
||||||
mee cget [-k <key>] <file>
|
mee cget [options] [-k <key>] <file>
|
||||||
mee cset -k <key> -v <value> <file> [-o <file>]
|
mee cset [options] -k <key> -v <value> <file> [-o <file>]
|
||||||
mee cdel -k <key> <file> [-o <file>]
|
mee cdel [options] -k <key> <file> [-o <file>]
|
||||||
mee (--help | --version)
|
mee (--help | --version)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-o, --output <file> output file. Same as input file by default.
|
-o, --output <file> output file. Same as input file by default.
|
||||||
-k, --key <key> set EXIF/JSON key.
|
-k, --key <key> set EXIF/JSON key.
|
||||||
-v, --value <value> EXIF/JSON key value.
|
-v, --value <value> EXIF/JSON key value.
|
||||||
|
-c, --custom make 'get -c' alias for 'cget', etc.
|
||||||
|
--json print JSON output if available.
|
||||||
|
--all perform action with all EXIF tags.
|
||||||
|
--debug enable debug messages.
|
||||||
--help print this message and exit.
|
--help print this message and exit.
|
||||||
--version print version and exit.
|
--version print version and exit.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
DEBUG enable debug messages.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use MEE as library
|
||||||
|
==================
|
||||||
|
|
||||||
|
Read sources and/or see `help(Mee)` for more info.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mee import Mee
|
||||||
|
|
||||||
|
mee = Mee("image.jpg")
|
||||||
|
mee.tags # current custom tags as dict
|
||||||
|
mee.set("is_nudes", True) # add key with bool value, VARIANT 1
|
||||||
|
mee.tags["is_nudes"] = True # add key with bool value, VARIANT 2
|
||||||
|
mee.commit() # write new EXIF to file
|
||||||
|
```
|
||||||
|
274
mee.py
274
mee.py
@ -5,141 +5,235 @@ Commands overview:
|
|||||||
cget, cset, cdel operate with JSON in UserComment EXIF tag.
|
cget, cset, cdel operate with JSON in UserComment EXIF tag.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mee ls <file>
|
mee ls [options] <file>
|
||||||
mee get [-k <key>] <file>
|
mee get [options] [-k <key>] <file>
|
||||||
mee set -k <key> -v <value> <file> [-o <file>]
|
mee set [options] -k <key> -v <value> <file> [-o <file>]
|
||||||
mee del -k <key> <file> [-o <file>]
|
mee del [options] (--all | -k <key>) <file> [-o <file>]
|
||||||
mee cget [-k <key>] <file>
|
mee cget [options] [-k <key>] <file>
|
||||||
mee cset -k <key> -v <value> <file> [-o <file>]
|
mee cset [options] -k <key> -v <value> <file> [-o <file>]
|
||||||
mee cdel -k <key> <file> [-o <file>]
|
mee cdel [options] -k <key> <file> [-o <file>]
|
||||||
mee (--help | --version)
|
mee (--help | --version)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-o, --output <file> output file. Same as input file by default.
|
-o, --output <file> output file. Same as input file by default.
|
||||||
-k, --key <key> set EXIF/JSON key.
|
-k, --key <key> set EXIF/JSON key.
|
||||||
-v, --value <value> EXIF/JSON key value.
|
-v, --value <value> EXIF/JSON key value.
|
||||||
|
-c, --custom make 'get -c' alias for 'cget', etc.
|
||||||
|
--json print JSON output if available.
|
||||||
|
--all perform action with all EXIF tags.
|
||||||
|
--debug enable debug messages.
|
||||||
--help print this message and exit.
|
--help print this message and exit.
|
||||||
--version print version and exit.
|
--version print version and exit.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
DEBUG enable debug messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
import re
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from docopt import docopt
|
|
||||||
from exif import Image
|
from exif import Image
|
||||||
|
from docopt import docopt
|
||||||
|
|
||||||
|
|
||||||
def open_image(infile: Path) -> Image:
|
class Mee:
|
||||||
with open(infile, "rb") as in_img:
|
"""
|
||||||
return Image(in_img)
|
Minimalistic EXIF Editor class. Custom tags implemented as JSON string
|
||||||
|
written in Exif.Photo.UserComment tag.
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
infile: `Path` Path to input file.
|
||||||
|
storage: `str` EXIF tag name used as custom tags storage.
|
||||||
|
image: `exif.Image` exif.Image object.
|
||||||
|
tags: `dict` Custom tags.
|
||||||
|
|
||||||
def commit_image(img: Image, infile: Path = None, outfile: Path = None) -> None:
|
Methods:
|
||||||
if not outfile:
|
get() Get custom tag value or all tags.
|
||||||
with NamedTemporaryFile(suffix="~") as out_tmp, open(infile, "wb") as out_img:
|
set() Set new custom tag or modify existing.
|
||||||
out_tmp.write(img.get_file())
|
delete() Remove custom tag.
|
||||||
out_tmp.seek(0)
|
commit() Write changs into file on disk.
|
||||||
out_img.write(out_tmp.read())
|
|
||||||
else:
|
|
||||||
with open(outfile, "wb") as out_img:
|
|
||||||
out_img.write(img.get_file())
|
|
||||||
|
|
||||||
|
You can directly use `exif.Image` methods::
|
||||||
|
|
||||||
def get_exif(key: str, infile: Path, echo: bool = True) -> None:
|
mee = Mee("image.jpg")
|
||||||
image = open_image(infile)
|
mee.image.get_all() # get all EXIF tags
|
||||||
value = image.get(key, "n/a")
|
|
||||||
if echo:
|
|
||||||
print(f"{key}: {value}")
|
|
||||||
else:
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
See also:
|
||||||
|
* https://gitlab.com/TNThieding/exif/-/blob/master/src/exif/_image.py
|
||||||
|
* https://exif.readthedocs.io/en/latest/usage.html
|
||||||
|
* https://exiv2.org/doc/exifcomment_8cpp-example.html
|
||||||
|
"""
|
||||||
|
|
||||||
def ls_exif(infile: Path) -> None:
|
def __init__(self, infile: Path):
|
||||||
image = open_image(infile)
|
self.infile = infile
|
||||||
tags = image.list_all()
|
self.storage = "user_comment"
|
||||||
print(tags)
|
self.logger = logging.getLogger("Mee")
|
||||||
|
|
||||||
|
with open(infile, "rb") as in_img:
|
||||||
|
self.image = Image(in_img)
|
||||||
|
|
||||||
def set_exif(key: str, value: str, infile: Path, outfile: Path = None) -> None:
|
|
||||||
image = open_image(infile)
|
|
||||||
image.set(key, value)
|
|
||||||
commit_image(image, infile, outfile)
|
|
||||||
|
|
||||||
|
|
||||||
def del_exif(key: str, infile: Path, outfile: Path = None) -> None:
|
|
||||||
image = open_image(infile)
|
|
||||||
image.delete(key)
|
|
||||||
commit_image(image, infile, outfile)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_comment(infile: Path) -> dict:
|
|
||||||
user_comment = get_exif("user_comment", infile, echo=False)
|
|
||||||
try:
|
|
||||||
return json.loads(user_comment)
|
|
||||||
except (TypeError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_json(infile: Path, key: str = None) -> None:
|
|
||||||
user_comment = get_user_comment(infile)
|
|
||||||
if key:
|
|
||||||
try:
|
try:
|
||||||
print(user_comment[key])
|
tags = self.exif_get(self.storage) or "{}"
|
||||||
except KeyError as e:
|
self.logger.debug("Raw custom tags: %s", tags)
|
||||||
sys.exit(f"No key: {e}")
|
if tags:
|
||||||
else:
|
self.tags = json.loads(tags)
|
||||||
print(json.dumps(user_comment, indent=4, sort_keys=True))
|
try:
|
||||||
|
# See WARNING in commit() method comments.
|
||||||
|
del self.tags["_"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
self.logger.debug("Got self.tags from JSON: %s", self.tags)
|
||||||
|
except (KeyError, json.JSONDecodeError) as error:
|
||||||
|
self.tags = {}
|
||||||
|
self.logger.debug(
|
||||||
|
"Set empty dict self.tags on error: %s", self.tags, error
|
||||||
|
)
|
||||||
|
|
||||||
|
def exif_get(self, key: str) -> str:
|
||||||
|
"""Return EXIF tag value."""
|
||||||
|
self.logger.debug("Call exif_get(): key=%s", key)
|
||||||
|
try:
|
||||||
|
return self.image.get(key, None)
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
def mod_json(key: str, value: str, infile: Path, outfile: Path = None, delete: bool = False) -> None:
|
def exif_set(self, key: str, value: Any) -> None:
|
||||||
user_comment = get_user_comment(infile)
|
"""Set EXIF tag. Wraps `exif.Image.set()`."""
|
||||||
if delete:
|
self.logger.debug("Call exif_set(): key=%s, value=%s", key, value)
|
||||||
del user_comment[key]
|
self.image.set(key, value)
|
||||||
else:
|
|
||||||
user_comment[key] = value
|
def exif_del(self, key: str) -> None:
|
||||||
try:
|
"""Delete EXIF tag. Wraps `exif.Image.delete()`."""
|
||||||
del_exif("user_comment", infile, outfile)
|
self.logger.debug("Call exif_del(): key=%s", key)
|
||||||
except AttributeError:
|
try:
|
||||||
pass
|
self.image.delete(key)
|
||||||
set_exif("user_comment", json.dumps(user_comment), infile, outfile)
|
except (KeyError, AttributeError) as error:
|
||||||
|
self.logger.debug("Pass on error in exif_del(): %s", error)
|
||||||
|
|
||||||
|
def get(self, key: str = None) -> Any:
|
||||||
|
"""Return custom tag value or all custom tags."""
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
return self.tags[key]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
return self.tags
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Set new or edit existing custom tag key:value pair."""
|
||||||
|
self.logger.debug("Call set(): key=%s, value=%s", key, value)
|
||||||
|
if not self.tags:
|
||||||
|
self.tags = {}
|
||||||
|
self.tags[key] = value
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
"""Remove custom tag."""
|
||||||
|
self.logger.debug("Call delete(): key=%s", key)
|
||||||
|
if self.tags:
|
||||||
|
try:
|
||||||
|
self.logger.debug("Call delete(): delete key")
|
||||||
|
del self.tags[key]
|
||||||
|
except KeyError:
|
||||||
|
self.logger.debug("Pass on KeyError in delete()")
|
||||||
|
|
||||||
|
def write_out(self, outfile: Optional[Path] = None) -> None:
|
||||||
|
"""Just write data to `outfile` or overwrite `infile`."""
|
||||||
|
if not outfile:
|
||||||
|
with NamedTemporaryFile() as out_tmp, open(
|
||||||
|
self.infile, "wb"
|
||||||
|
) as out_img:
|
||||||
|
out_tmp.write(self.image.get_file())
|
||||||
|
out_tmp.seek(0)
|
||||||
|
out_img.write(out_tmp.read())
|
||||||
|
else:
|
||||||
|
with open(outfile, "wb") as out_img:
|
||||||
|
out_img.write(self.image.get_file())
|
||||||
|
|
||||||
|
def commit(self, outfile: Optional[Path] = None) -> None:
|
||||||
|
"""Safely write changes to file."""
|
||||||
|
self.logger.debug("Call commit(): outfile=%s", outfile)
|
||||||
|
# Remove old tag from EXIF.
|
||||||
|
# You cannot replace the data if it is longer than it was originally.
|
||||||
|
# Therefore, old data from the tag is always preliminarily removed.
|
||||||
|
self.exif_del(self.storage)
|
||||||
|
if not self.tags:
|
||||||
|
# !!! WARNING !!!
|
||||||
|
# If you write empty JSON to EXIF, then EXIF will be considered
|
||||||
|
# corrupted! You'll get a warning in exiftool, and you won't be
|
||||||
|
# able to do anything with the metadata. Here I add a placeholder
|
||||||
|
# key so that valid JSON is always written to UserComment tag.
|
||||||
|
self.tags = {"_": None}
|
||||||
|
string = json.dumps(self.tags, separators=(",", ":"))
|
||||||
|
self.logger.debug("Commit: self.tags: %s", self.tags)
|
||||||
|
self.logger.debug("Commit: Data to write: %s", string)
|
||||||
|
self.exif_set(self.storage, string)
|
||||||
|
# Write data to file
|
||||||
|
self.write_out(outfile)
|
||||||
|
|
||||||
|
|
||||||
def cli() -> None:
|
def cli() -> None:
|
||||||
args = docopt(__doc__, version=__version__)
|
args = docopt(__doc__, version=__version__)
|
||||||
key = args["--key"]
|
key = args["--key"]
|
||||||
value = args["--value"]
|
value = args["--value"]
|
||||||
infile = args['<file>']
|
infile = args["<file>"]
|
||||||
outfile = args['--output']
|
outfile = args["--output"]
|
||||||
|
|
||||||
if args['ls']:
|
if args["--debug"] or os.getenv("DEBUG"):
|
||||||
ls_exif(infile)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
if args['get']:
|
mee = Mee(infile)
|
||||||
|
|
||||||
|
if args["ls"]:
|
||||||
|
print(mee.image.list_all())
|
||||||
|
|
||||||
|
if args["get"] and not args["--custom"]:
|
||||||
if not key:
|
if not key:
|
||||||
ls_exif(infile)
|
tags = mee.image.get_all()
|
||||||
|
if args['--json']:
|
||||||
|
print(json.dumps(tags, indent=4))
|
||||||
|
else:
|
||||||
|
print(tags)
|
||||||
else:
|
else:
|
||||||
get_exif(key, infile)
|
tag = mee.exif_get(key)
|
||||||
|
if args['--json']:
|
||||||
|
print(json.dumps(tag, indent=4))
|
||||||
|
else:
|
||||||
|
print(tag)
|
||||||
|
|
||||||
if args["set"]:
|
if args["set"] and not args["--custom"]:
|
||||||
set_exif(key, value, infile, outfile)
|
mee.exif_set(key, value)
|
||||||
|
mee.commit(outfile)
|
||||||
|
|
||||||
if args['del']:
|
if args["del"] and not args["--custom"]:
|
||||||
del_exif(key, infile, outfile)
|
if args["--all"]:
|
||||||
|
mee.image.delete_all()
|
||||||
|
else:
|
||||||
|
mee.image.delete(key)
|
||||||
|
mee.write_out(outfile)
|
||||||
|
|
||||||
if args['cget']:
|
if args["cget"] or (args["get"] and args["--custom"]):
|
||||||
get_json(infile, key)
|
if not key:
|
||||||
|
print(json.dumps(mee.tags, indent=4))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
print(mee.tags[key])
|
||||||
|
except KeyError:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if args["cset"]:
|
if args["cset"] or (args["set"] and args["--custom"]):
|
||||||
mod_json(key, value, infile, outfile)
|
mee.set(key, value)
|
||||||
|
mee.commit(outfile)
|
||||||
|
|
||||||
if args['cdel']:
|
if args["cdel"] or args["del"] and args["--custom"]:
|
||||||
mod_json(key, None, infile, outfile, delete=True)
|
mee.delete(key)
|
||||||
|
mee.commit(outfile)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
Loading…
Reference in New Issue
Block a user