├── .gitignore ├── MANIFEST.in ├── test ├── data │ ├── conker.jpg │ ├── conker.txt │ ├── noexif.jpg │ ├── rose.jpg │ ├── noexif.txt │ └── rose.txt └── test.py ├── scripts ├── dump_exif.py ├── getgps.py ├── remove_metadata.py ├── noop.py ├── setgps.py ├── dump_timestamp.py └── timezone.py ├── PKG-INFO ├── LICENSE ├── setup.py ├── examples └── hello.py ├── README.md └── pexif.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build 3 | dist 4 | MANIFEST 5 | 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include test *.py *.jpg *.txt 2 | -------------------------------------------------------------------------------- /test/data/conker.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennoleslie/pexif/HEAD/test/data/conker.jpg -------------------------------------------------------------------------------- /test/data/conker.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennoleslie/pexif/HEAD/test/data/conker.txt -------------------------------------------------------------------------------- /test/data/noexif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennoleslie/pexif/HEAD/test/data/noexif.jpg -------------------------------------------------------------------------------- /test/data/rose.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bennoleslie/pexif/HEAD/test/data/rose.jpg -------------------------------------------------------------------------------- /test/data/noexif.txt: -------------------------------------------------------------------------------- 1 | 2 | Section: [ APP0] Size: 14 3 | Section: [ DQT] Size: 130 4 | Section: [ SOF0] Size: 15 5 | Section: [ DHT] Size: 416 6 | Section: [ SOS] Size: 10 Image data size: 135204 7 | -------------------------------------------------------------------------------- /scripts/dump_exif.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pexif import JpegFile 4 | import sys 5 | 6 | usage = """Usage: dump_exif.py filename.jpg""" 7 | 8 | if len(sys.argv) != 2: 9 | print >> sys.stderr, usage 10 | sys.exit(1) 11 | 12 | try: 13 | ef = JpegFile.fromFile(sys.argv[1]) 14 | ef.dump() 15 | except IOError: 16 | type, value, traceback = sys.exc_info() 17 | print >> sys.stderr, "Error opening file:", value 18 | except JpegFile.InvalidFile: 19 | type, value, traceback = sys.exc_info() 20 | print >> sys.stderr, "Error opening file:", value 21 | -------------------------------------------------------------------------------- /scripts/getgps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pexif import JpegFile 3 | import sys 4 | 5 | usage = """Usage: getgps.py filename.jpg""" 6 | 7 | if len(sys.argv) != 2: 8 | print >> sys.stderr, usage 9 | sys.exit(1) 10 | 11 | try: 12 | ef = JpegFile.fromFile(sys.argv[1]) 13 | print ef.get_geo() 14 | except IOError: 15 | type, value, traceback = sys.exc_info() 16 | print >> sys.stderr, "Error opening file:", value 17 | except JpegFile.NoSection: 18 | type, value, traceback = sys.exc_info() 19 | print >> sys.stderr, "Error get GPS info:", value 20 | except JpegFile.InvalidFile: 21 | type, value, traceback = sys.exc_info() 22 | print >> sys.stderr, "Error opening file:", value 23 | 24 | -------------------------------------------------------------------------------- /PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.0 2 | Name: pexif 3 | Version: 0.11 4 | Summary: A module for editing JPEG EXIF data 5 | Home-page: http://www.benno.id.au/code/pexif/ 6 | Author: Ben Leslie 7 | Author-email: benno@benno.id.au 8 | License: http://www.opensource.org/licenses/mit-license.php 9 | Download-URL: http://www.benno.id.au/code/pexif/pexif-0.11.tar.gz 10 | Description: This module allows you to parse and edit the EXIF data tags in a JPEG image. 11 | Platform: any 12 | Classifier: Development Status :: 4 - Beta 13 | Classifier: Intended Audience :: Developers 14 | Classifier: Operating System :: OS Independent 15 | Classifier: Programming Language :: Python 16 | Classifier: License :: OSI Approved :: MIT License 17 | Classifier: Topic :: Multimedia :: Graphics 18 | -------------------------------------------------------------------------------- /scripts/remove_metadata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pexif import JpegFile 3 | import sys 4 | 5 | usage = """Usage: remove_metadata.py filename.jpg""" 6 | 7 | if len(sys.argv) != 4: 8 | print >> sys.stderr, usage 9 | sys.exit(1) 10 | 11 | try: 12 | ef = JpegFile.fromFile(sys.argv[1]) 13 | ef.remove_metadata(paranoid=True) 14 | except IOError: 15 | type, value, traceback = sys.exc_info() 16 | print >> sys.stderr, "Error opening file:", value 17 | except JpegFile.InvalidFile: 18 | type, value, traceback = sys.exc_info() 19 | print >> sys.stderr, "Error opening file:", value 20 | 21 | try: 22 | ef.writeFile(sys.argv[1]) 23 | except IOError: 24 | type, value, traceback = sys.exc_info() 25 | print >> sys.stderr, "Error saving file:", value 26 | -------------------------------------------------------------------------------- /scripts/noop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pexif import JpegFile 4 | import sys 5 | 6 | usage = """Usage: dump_exif.py filename.jpg out.jpg""" 7 | 8 | if len(sys.argv) != 3: 9 | print >> sys.stderr, usage 10 | sys.exit(1) 11 | 12 | try: 13 | ef = JpegFile.fromFile(sys.argv[1]) 14 | except IOError: 15 | type, value, traceback = sys.exc_info() 16 | print >> sys.stderr, "Error opening file:", value 17 | sys.exit(1) 18 | except JpegFile.InvalidFile: 19 | type, value, traceback = sys.exc_info() 20 | print >> sys.stderr, "Error opening file:", value 21 | sys.exit(1) 22 | 23 | try: 24 | ef.writeFile(sys.argv[2]) 25 | except IOError: 26 | type, value, traceback = sys.exc_info() 27 | print >> sys.stderr, "Error saving file:", value 28 | sys.exit(1) 29 | -------------------------------------------------------------------------------- /scripts/setgps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pexif import JpegFile 3 | import sys 4 | 5 | usage = """Usage: setgps.py filename.jpg lat lng""" 6 | 7 | if len(sys.argv) != 4: 8 | print >> sys.stderr, usage 9 | sys.exit(1) 10 | 11 | try: 12 | ef = JpegFile.fromFile(sys.argv[1]) 13 | ef.set_geo(float(sys.argv[2]), float(sys.argv[3])) 14 | except IOError: 15 | type, value, traceback = sys.exc_info() 16 | print >> sys.stderr, "Error opening file:", value 17 | except JpegFile.InvalidFile: 18 | type, value, traceback = sys.exc_info() 19 | print >> sys.stderr, "Error opening file:", value 20 | 21 | try: 22 | ef.writeFile(sys.argv[1]) 23 | except IOError: 24 | type, value, traceback = sys.exc_info() 25 | print >> sys.stderr, "Error saving file:", value 26 | 27 | -------------------------------------------------------------------------------- /scripts/dump_timestamp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pexif import JpegFile 4 | import sys 5 | 6 | usage = """Usage: dump_timestamp.py filename.jpg""" 7 | 8 | if len(sys.argv) != 2: 9 | print >> sys.stderr, usage 10 | sys.exit(1) 11 | 12 | try: 13 | ef = JpegFile.fromFile(sys.argv[1]) 14 | primary = ef.get_exif().get_primary() 15 | print "Primary DateTime :", primary.DateTime 16 | print "Extended DateTimeOriginal :", primary.ExtendedEXIF.DateTimeOriginal 17 | print "Extended DateTimeDigitized:", primary.ExtendedEXIF.DateTimeDigitized 18 | except IOError: 19 | type, value, traceback = sys.exc_info() 20 | print >> sys.stderr, "Error opening file:", value 21 | except JpegFile.InvalidFile: 22 | type, value, traceback = sys.exc_info() 23 | print >> sys.stderr, "Error opening file:", value 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008-2013 Ben Leslie 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | version = "0.15" 6 | 7 | """Setup script for pexif""" 8 | 9 | setup ( 10 | name = "pexif", 11 | version = version, 12 | description = "A module for editing JPEG EXIF data", 13 | long_description = "This module allows you to parse and edit the EXIF data tags in a JPEG image.", 14 | author = "Ben Leslie", 15 | author_email = "benno@benno.id.au", 16 | url = "http://www.benno.id.au/code/pexif/", 17 | license = "http://www.opensource.org/licenses/mit-license.php", 18 | py_modules = ["pexif"], 19 | scripts = ["scripts/dump_exif.py", "scripts/setgps.py", "scripts/getgps.py", "scripts/noop.py", 20 | "scripts/timezone.py", "scripts/remove_metadata.py"], 21 | platforms = ["any"], 22 | classifiers = ["Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "License :: OSI Approved :: MIT License", 27 | "Topic :: Multimedia :: Graphics"] 28 | ) 29 | -------------------------------------------------------------------------------- /examples/hello.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example showing how to set the Image title on a jpeg file. 3 | """ 4 | 5 | import pexif 6 | 7 | # Modify the exif in a file 8 | img = pexif.JpegFile.fromFile("test/data/rose.jpg") 9 | img.exif.primary.ImageDescription = "Hello world!" 10 | img.writeFile("hello1.jpg") 11 | 12 | # Add exif in a file 13 | img = pexif.JpegFile.fromFile("test/data/noexif.jpg") 14 | img.exif.primary.ImageDescription = "Hello world!" 15 | img.exif.primary.ExtendedEXIF.UserComment = "a simple comment" 16 | img.writeFile("hello2.jpg") 17 | 18 | # Copy some exif field from one to another 19 | primary_src = pexif.JpegFile.fromFile("test/data/rose.jpg").exif.primary 20 | img_dst = pexif.JpegFile.fromFile("test/data/noexif.jpg") 21 | primary_dst = img_dst.exif.primary 22 | primary_dst.Model = primary_src.Model 23 | primary_dst.Make = primary_src.Make 24 | img_dst.writeFile("hello3.jpg") 25 | 26 | # Copy entire exif from one to another (where there is no exif) 27 | img_src = pexif.JpegFile.fromFile("test/data/rose.jpg") 28 | img_dst = pexif.JpegFile.fromFile("test/data/noexif.jpg") 29 | img_dst.import_exif(img_src.exif) 30 | img_dst.writeFile("hello4.jpg") 31 | 32 | # Copy entire exif from one to another (where there is exif) 33 | img_src = pexif.JpegFile.fromFile("test/data/rose.jpg") 34 | img_dst = pexif.JpegFile.fromFile("test/data/conker.jpg") 35 | img_dst.import_exif(img_src.exif) 36 | img_dst.writeFile("hello5.jpg") 37 | 38 | # Remove metadata 39 | img_dst = pexif.JpegFile.fromFile("test/data/rose.jpg") 40 | img_dst.remove_metadata(paranoid=True) 41 | img_dst.writeFile("hello6.jpg") 42 | 43 | # Import metadata 44 | img_src = pexif.JpegFile.fromFile("test/data/conker.jpg") 45 | img_dst = pexif.JpegFile.fromFile("test/data/rose.jpg") 46 | img_dst.import_metadata(img_src) 47 | img_dst.writeFile("hello7.jpg") 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python EXIF parsing 2 | 3 | **pexif** is a Python library for parsing and more importantly 4 | editing EXIF data in JPEG files. 5 | 6 | This grew out of a need to add GPS tagged data to my images, 7 | Unfortunately the other libraries out there couldn't do updates and 8 | didn't seem easily architectured to be able to add such a thing. Ain't 9 | reusable software grand! 10 | 11 | ## Apps 12 | 13 | - **dump_exif.py**: Output the EXIF file from a given file. 14 | - **setgps.py**: Set the GPS metadata on a file. 15 | - **getgps.py**: Get the GPS metadata from a file. 16 | - **noop.py**: This is a no-op on a jpeg file. Useful for testing images are preserved across 17 | operations using pexif. Note that the binary data will not be exact as pexif will compress 18 | unused space in the file, however running it on a file twice should end up with the same data. 19 | 20 | ## Examples 21 | 22 | - **hello.py**: Add a simple description to a photo. 23 | 24 | ## Status: 25 | 26 | **WARNING**: This could destroy your images!! Backup your images before using. 27 | 28 | Currently it parses files from my Canon without a problem, and is able to 29 | add a GPS tag without corrupting the rest of the image. 30 | 31 | ## References 32 | 33 | This work couldn't be done with the reference for the spec. In particular I worked to: 34 | 35 | - http://www.exiv2.org/Exif2-2.PDF 36 | 37 | For the format of the Canon stuff I used: 38 | 39 | - http://www.burren.cx/david/canon.html 40 | 41 | For the format of FujiFILM make note: 42 | 43 | - http://www.ozhiker.com/electronics/pjmt/jpeg_info/fujifilm_mn.html 44 | 45 | ## Acknowledgments: 46 | 47 | [Nick Carter](nick.carter@roke.co.uk) provided conker.jpg which is used for testing FUJIFILM exif data. 48 | 49 | [Nick Burch](nick@gagravarr.org) provided noexif.jpg which is used 50 | for testing inputs that don't have an existing EXIF segment. Nick burch 51 | also found an error in GeoTags on the S60. 52 | 53 | [Christopher Jones](short.jones.cipher@gmail.com) who inspired updating 54 | my examples to show how copying EXIF data from one jpeg to another. 55 | 56 | [Roland Klabunde](roland.klabunde@freenet.de) who also found a bug in 57 | the GeoTag functionality. 58 | 59 | [Marcell Lengyel](miketkf@gmail.com) for adding better support for 60 | Canon g3 and FujiFilm z5fd. 61 | 62 | [Andrew Baumann](http://ab.id.au/) for finding a bug in dealing with 63 | extended IFD sections, and supplying the timezone adjustment script. 64 | -------------------------------------------------------------------------------- /scripts/timezone.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Utility to adjust the EXIF timestamps in JPEG files by a constant offset. 5 | 6 | Requires Benno's pexif library: http://code.google.com/p/pexif/ 7 | 8 | -- Andrew Baumann , 20080716 9 | """ 10 | 11 | import sys 12 | from pexif import JpegFile, EXIF_OFFSET 13 | from datetime import timedelta, datetime 14 | from optparse import OptionParser 15 | 16 | DATETIME_EMBEDDED_TAGS = ["DateTimeOriginal", "DateTimeDigitized"] 17 | TIME_FORMAT = '%Y:%m:%d %H:%M:%S' 18 | 19 | def parse_args(): 20 | p = OptionParser(usage='%prog hours file.jpg...', 21 | description='adjusts timestamps in EXIF metadata by given offset') 22 | options, args = p.parse_args() 23 | if len(args) < 2: 24 | p.error('not enough arguments') 25 | try: 26 | hours = int(args[0]) 27 | except: 28 | p.error('invalid time offset, must be an integral number of hours') 29 | return hours, args[1:] 30 | 31 | def adjust_time(primary, delta): 32 | def adjust_tag(timetag, delta): 33 | dt = datetime.strptime(timetag, TIME_FORMAT) 34 | dt += delta 35 | return dt.strftime(TIME_FORMAT) 36 | 37 | if primary.DateTime: 38 | primary.DateTime = adjust_tag(primary.DateTime, delta) 39 | 40 | embedded = primary[EXIF_OFFSET] 41 | if embedded: 42 | for tag in DATETIME_EMBEDDED_TAGS: 43 | if embedded[tag]: 44 | embedded[tag] = adjust_tag(embedded[tag], delta) 45 | 46 | def main(): 47 | hours, files = parse_args() 48 | delta = timedelta(hours=hours) 49 | 50 | for fname in files: 51 | try: 52 | jf = JpegFile.fromFile(fname) 53 | except (IOError, JpegFile.InvalidFile): 54 | type, value, traceback = sys.exc_info() 55 | print >> sys.stderr, "Error reading %s:" % fname, value 56 | return 1 57 | 58 | exif = jf.get_exif() 59 | if exif: 60 | primary = exif.get_primary() 61 | if exif is None or primary is None: 62 | print >> sys.stderr, "%s has no EXIF tag, skipping" % fname 63 | continue 64 | 65 | adjust_time(primary, delta) 66 | 67 | try: 68 | jf.writeFile(fname) 69 | except IOError: 70 | type, value, traceback = sys.exc_info() 71 | print >> sys.stderr, "Error saving %s:" % fname, value 72 | return 1 73 | 74 | return 0 75 | 76 | if __name__ == "__main__": 77 | sys.exit(main()) 78 | -------------------------------------------------------------------------------- /test/data/rose.txt: -------------------------------------------------------------------------------- 1 | 2 | Section: [ APP0] Size: 14 3 | Section: [ EXIF] Size: 6441 4 | <--- TIFF Ifd start ---> 5 | Camera Make Canon 6 | Camera Model Canon DIGITAL IXUS II 7 | Orientation of image 1 8 | X Resolution 180 / 1 9 | Y Resolution 180 / 1 10 | Unit of X and Y resolution 2 11 | File change data and time 2006:01:14 15:35:54 12 | Y and C positioning 1 13 | <--- Extended EXIF start ---> 14 | Exposure Time 1 / 500 15 | F Number 80 / 10 16 | Exif Version ['0', '2', '2', '0'] 17 | Date of original data generation 2006:01:14 15:35:54 18 | Date of digital data generation 2006:01:14 15:35:54 19 | Meaning of each component ['\x01', '\x02', '\x03', '\x00'] 20 | Image compression mode 3 / 1 21 | Shutter speed 287 / 32 22 | Aperture 192 / 32 23 | Exposure bias -3 / 3 24 | Maximum lens apeture 107 / 32 25 | Metering mode 2 26 | Flash 16 27 | Lens focal length 215 / 32 28 | <--- Canon start ---> 29 | 0x1 [92, 1, 0, 3, 0, 0, 0, 4, 0, 1, 0, 1, 0, 0, 0, 0, 19, 5, 3, 16385, 0, 0, 65535, 346, 173, 32, 106, 190, 0, 0, 0, 0, 0, 1, 65535, 0, 2048, 2048, 0, 0, 2, 0, 32767, 0, 0, 0] 30 | 0x2 [2, 215, 213, 159] 31 | 0x3 [1024, 0, 0, 0] 32 | 0x4 [68, 0, 224, 221, 192, 287, 65504, 2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 27, 0, 190, 288, 0, 0, 0, 250, 0, 0, 0, 0, 0, 0, 13] 33 | 0x0 [0, 0, 0, 0, 0, 0] 34 | 0x0 [0, 0, 0, 0] 35 | 0x12 [9, 9, 2048, 1536, 2048, 256, 369, 42, 65166, 0, 370, 65166, 0, 370, 65166, 0, 370, 65488, 65488, 65488, 0, 0, 0, 48, 48, 48, 0, 0] 36 | 0x13 [0, 0, 0, 0] 37 | Image Type IMG:DIGITAL IXUS II JPEG 38 | Firmware Revision Firmware Version 2.01 39 | Image Number 1080836 40 | Owner Name 41 | 0x10 19070976 42 | 0xd [68, 9, 505, 502, 505, 505, 505, 503, 505, 499, 505, 64, 0, 0, 95, 1, 0, 10, 0, 0, 0, 23, 264, 1, 0, 1010, 1001, 0, 0, 0, 0, 146, 0, 65440] 43 | <--- Canon end ---> 44 | User comments ['\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00', '\x00'] 45 | Supported Flashpix version ['0', '1', '0', '0'] 46 | Color Space Information 1 47 | Valid image width 2048 48 | Valid image height 1536 49 | 0xa005 6186 50 | Focal plane X resolution 2048000 / 208 51 | Focal plane Y resolution 1536000 / 156 52 | Focal plane resolution unit 2 53 | Sensing method 2 54 | File source  55 | Customer image processing 0 56 | Exposure mode 1 57 | White balance 1 58 | Digital zoom ratio 2048 / 2048 59 | Scene capture type 0 60 | <--- Extended EXIF end ---> 61 | <--- TIFF Ifd end ---> 62 | <--- Thumbnail start ---> 63 | Image width 200 64 | Image height 150 65 | Compression Scheme 6 66 | Orientation of image 1 67 | Offset to JPEG SOI 1624 68 | Bytes of JPEG data 4811 69 | <--- Thumbnail end ---> 70 | Section: [ DQT] Size: 65 71 | Section: [ DQT] Size: 65 72 | Section: [ SOF2] Size: 15 73 | Section: [ DHT] Size: 24 74 | Section: [ DHT] Size: 22 75 | Section: [ SOS] Size: 10 Image data size: 4023 76 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pexif 3 | import StringIO 4 | import difflib 5 | 6 | test_data = [ 7 | ("test/data/rose.jpg", "test/data/rose.txt"), 8 | ("test/data/conker.jpg", "test/data/conker.txt"), 9 | ("test/data/noexif.jpg", "test/data/noexif.txt"), 10 | ] 11 | 12 | DEFAULT_TESTFILE = test_data[0][0] 13 | NONEXIST_TESTFILE = "test/data/noexif.jpg" 14 | 15 | class TestLoadFunctions(unittest.TestCase): 16 | def test_fromFile(self): 17 | # Simple test ensures we can load and parse a file from filename 18 | for test_file, _ in test_data: 19 | pexif.JpegFile.fromFile(test_file) 20 | 21 | def test_fromString(self): 22 | # Simple test ensures we can load and parse a file passed as a string 23 | for test_file, _ in test_data: 24 | fd = open(test_file, "rb") 25 | data = fd.read() 26 | fd.close() 27 | pexif.JpegFile.fromString(data) 28 | 29 | def test_fromFd(self): 30 | # Simple test ensure we can load and parse a file passed as a fd 31 | for test_file, _ in test_data: 32 | fd = open(test_file, "rb") 33 | pexif.JpegFile.fromFd(fd) 34 | 35 | def test_emptyData(self): 36 | # Simple test ensures that empty string fails 37 | self.assertRaises(pexif.JpegFile.InvalidFile, pexif.JpegFile.fromString, "") 38 | 39 | def test_badData(self): 40 | # Simple test ensures that random crap doesn't get parsed 41 | self.assertRaises(pexif.JpegFile.InvalidFile, pexif.JpegFile.fromString, 42 | "asl;dkfjasl;kdjfsld") 43 | 44 | def test_regen(self): 45 | # Test to ensure the new file matches the existing file 46 | for test_file, _ in test_data: 47 | data = open(test_file, "rb").read() 48 | jpeg = pexif.JpegFile.fromString(data) 49 | new_data = jpeg.writeString() 50 | self.assertEqual(data, new_data, "Binary differs for <%s>" % test_file) 51 | 52 | def test_dump(self): 53 | # Test that the dumped data is as expected. 54 | for test_file, expected_file in test_data: 55 | expected = open(expected_file, 'rb').read() 56 | jpeg = pexif.JpegFile.fromFile(test_file) 57 | out = StringIO.StringIO() 58 | jpeg.dump(out) 59 | res = "Error in file <%s>\n" % test_file 60 | x = difflib.unified_diff(expected.split('\n'), out.getvalue().split('\n')) 61 | for each in x: 62 | res += each 63 | res += '\n' 64 | self.assertEqual(expected, out.getvalue(), res) 65 | 66 | class TestExifFunctions(unittest.TestCase): 67 | 68 | def test_badendian(self): 69 | data = list(open(DEFAULT_TESTFILE, "rb").read()) 70 | # Now trash the exif signature 71 | assert(data[0x1E] == 'I') 72 | data[0x1E] = '0' 73 | self.assertRaises(pexif.JpegFile.InvalidFile, pexif.JpegFile.fromString, "".join(data)) 74 | 75 | def test_badtifftag(self): 76 | data = list(open(DEFAULT_TESTFILE, "rb").read()) 77 | # Now trash the exif signature 78 | assert(data[0x20] == '\x2a') 79 | data[0x20] = '0' 80 | self.assertRaises(pexif.JpegFile.InvalidFile, pexif.JpegFile.fromString, "".join(data)) 81 | 82 | def test_goodexif(self): 83 | for test_file, _ in test_data: 84 | jp = pexif.JpegFile.fromFile(test_file) 85 | jp.get_exif() 86 | 87 | def test_noexif(self): 88 | jp = pexif.JpegFile.fromFile(NONEXIST_TESTFILE) 89 | self.assertEqual(jp.get_exif(), None) 90 | 91 | def test_noexif_create(self): 92 | jp = pexif.JpegFile.fromFile(NONEXIST_TESTFILE) 93 | self.assertNotEqual(jp.get_exif(create=True), None) 94 | 95 | def test_getattr_nonexist(self): 96 | for test_file, _ in test_data: 97 | attr = pexif.JpegFile.fromFile(test_file). \ 98 | get_exif(create=True). \ 99 | get_primary(create=True) 100 | self.assertEqual(attr["ImageWidth"], None) 101 | def foo(): 102 | attr.ImageWidth 103 | self.assertRaises(AttributeError, foo) 104 | 105 | def test_getattr_exist(self): 106 | attr = pexif.JpegFile.fromFile(DEFAULT_TESTFILE).get_exif().get_primary() 107 | self.assertEqual(attr["Make"], "Canon") 108 | self.assertEqual(attr.Make, "Canon") 109 | 110 | def test_setattr_nonexist(self): 111 | for test_file, _ in test_data: 112 | attr = pexif.JpegFile.fromFile(test_file). \ 113 | get_exif(create=True).get_primary(create=True) 114 | attr["ImageWidth"] = 3 115 | self.assertEqual(attr["ImageWidth"], 3) 116 | 117 | def test_setattr_exist(self): 118 | for test_file, _ in test_data: 119 | attr = pexif.JpegFile.fromFile(test_file). \ 120 | get_exif(create=True). \ 121 | get_primary(create=True) 122 | attr.Make = "CanonFoo" 123 | self.assertEqual(attr.Make, "CanonFoo") 124 | attr["Make"] = "CanonFoo" 125 | self.assertEqual(attr["Make"], "CanonFoo") 126 | 127 | def test_setattr_exist_none(self): 128 | for test_file, _ in test_data: 129 | attr = pexif.JpegFile.fromFile(test_file). \ 130 | get_exif(create=True). \ 131 | get_primary(create=True) 132 | attr["Make"] = None 133 | self.assertEqual(attr["Make"], None) 134 | attr.Make = "Foo" 135 | self.assertEqual(attr["Make"], "Foo") 136 | del attr.Make 137 | self.assertEqual(attr["Make"], None) 138 | 139 | def test_add_geo(self): 140 | for test_file, _ in test_data: 141 | jf = pexif.JpegFile.fromFile(test_file) 142 | try: 143 | jf.get_geo() 144 | return 145 | except jf.NoSection: 146 | pass 147 | attr = jf.get_exif(create=True).get_primary(create=True) 148 | gps = attr.new_gps() 149 | gps["GPSLatitudeRef"] = "S" 150 | gps["GPSLongitudeRef"] = "E" 151 | data = jf.writeString() 152 | jf2 = pexif.JpegFile.fromString(data) 153 | self.assertEqual(jf2.get_exif().get_primary().GPS \ 154 | ["GPSLatitudeRef"], "S") 155 | 156 | def test_simple_add_geo(self): 157 | for test_file, _ in test_data: 158 | jf = pexif.JpegFile.fromFile(test_file) 159 | (lat, lng) = (-37.312312, 45.412321) 160 | jf.set_geo(lat, lng) 161 | new_file = jf.writeString() 162 | new = pexif.JpegFile.fromString(new_file) 163 | new_lat, new_lng = new.get_geo() 164 | self.assertAlmostEqual(lat, new_lat, 6) 165 | self.assertAlmostEqual(lng, new_lng, 6) 166 | 167 | def test_simple_add_geo2(self): 168 | for test_file, _ in test_data: 169 | jf = pexif.JpegFile.fromFile(test_file) 170 | (lat, lng) = (51.522, -1.455) 171 | jf.set_geo(lat, lng) 172 | new_file = jf.writeString() 173 | new = pexif.JpegFile.fromString(new_file) 174 | new_lat, new_lng = new.get_geo() 175 | self.assertAlmostEqual(lat, new_lat, 6) 176 | self.assertAlmostEqual(lng, new_lng, 6) 177 | 178 | def test_simple_add_geo3(self): 179 | for test_file, _ in test_data: 180 | jf = pexif.JpegFile.fromFile(test_file) 181 | (lat, lng) = (51.522, -1.2711) 182 | jf.set_geo(lat, lng) 183 | new_file = jf.writeString() 184 | new = pexif.JpegFile.fromString(new_file) 185 | new_lat, new_lng = new.get_geo() 186 | self.assertAlmostEqual(lat, new_lat, 6) 187 | self.assertAlmostEqual(lng, new_lng, 6) 188 | 189 | def test_get_geo(self): 190 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE) 191 | self.assertRaises(pexif.JpegFile.NoSection, jf.get_geo) 192 | 193 | def test_exif_property(self): 194 | def test_get(): 195 | foo = jf.exif 196 | 197 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE, mode="ro") 198 | self.assertEqual(jf.exif.__class__, pexif.ExifSegment) 199 | 200 | # exif doesn't exist 201 | jf = pexif.JpegFile.fromFile(NONEXIST_TESTFILE, mode="ro") 202 | self.assertRaises(AttributeError, test_get) 203 | 204 | def test_invalid_set(self): 205 | """Test that setting an invalid tag raise an attribute error""" 206 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE) 207 | def test_set(): 208 | jf.exif.primary.UserComment = "foobar" 209 | self.assertRaises(AttributeError, test_set) 210 | 211 | def test_invalid_set_embedded(self): 212 | """Test that setting an embedded tag raises a type error""" 213 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE) 214 | def test_set(): 215 | jf.exif.primary.ExtendedEXIF = 5 216 | self.assertRaises(TypeError, test_set) 217 | 218 | def test_set_embedded(self): 219 | """Test that setting an embedded tag raises a type error""" 220 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE) 221 | ext_exif = pexif.IfdExtendedEXIF(jf.exif.primary.e, 0, "rw", jf) 222 | jf.exif.primary.ExtendedEXIF = ext_exif 223 | 224 | def test_set_xy_dimensions(self): 225 | """Test setting PixelXDimension and PixelYDimension.""" 226 | jf = pexif.JpegFile.fromFile(DEFAULT_TESTFILE) 227 | jf.exif.primary.ExtendedEXIF.PixelXDimension = [1600] 228 | jf.exif.primary.ExtendedEXIF.PixelYDimension = [1200] 229 | new = jf.writeString() 230 | nf = pexif.JpegFile.fromString(new) 231 | self.assertEqual(nf.exif.primary.ExtendedEXIF.PixelXDimension, [1600]) 232 | self.assertEqual(nf.exif.primary.ExtendedEXIF.PixelYDimension, [1200]) 233 | 234 | 235 | if __name__ == "__main__": 236 | unittest.main() 237 | -------------------------------------------------------------------------------- /pexif.py: -------------------------------------------------------------------------------- 1 | """ 2 | pexif is a module which allows you to view and modify meta-data in 3 | JPEG/JFIF/EXIF files. 4 | 5 | The main way to use this is to create an instance of the JpegFile class. 6 | This should be done using one of the static factory methods fromFile, 7 | fromString or fromFd. 8 | 9 | After manipulating the object you can then write it out using one of the 10 | writeFile, writeString or writeFd methods. 11 | 12 | The get_exif() method on JpegFile returns the ExifSegment if one exists. 13 | 14 | Example: 15 | 16 | jpeg = pexif.JpegFile.fromFile("foo.jpg") 17 | exif = jpeg.get_exif() 18 | .... 19 | jpeg.writeFile("new.jpg") 20 | 21 | For photos that don't currently have an exef segment you can specify 22 | an argument which will create the exef segment if it doesn't exist. 23 | 24 | Example: 25 | 26 | jpeg = pexif.JpegFile.fromFile("foo.jpg") 27 | exif = jpeg.get_exif(create=True) 28 | .... 29 | jpeg.writeFile("new.jpg") 30 | 31 | The JpegFile class handles file that are formatted in something 32 | approach the JPEG specification (ISO/IEC 10918-1) Annex B 'Compressed 33 | Data Formats', and JFIF and EXIF standard. 34 | 35 | In particular, the way a 'jpeg' file is treated by pexif is that 36 | a JPEG file is made of a series of segments followed by the image 37 | data. In particular it should look something like: 38 | 39 | [ SOI | | SOS | image data | EOI ] 40 | 41 | So, the library expects a Start-of-Image marker, followed 42 | by an arbitrary number of segment (assuming that a segment 43 | has the format: 44 | 45 | [ <0xFF> ] 46 | 47 | and that there are no gaps between segments. 48 | 49 | The last segment must be the Start-of-Scan header, and the library 50 | assumes that following Start-of-Scan comes the image data, finally 51 | followed by the End-of-Image marker. 52 | 53 | This is probably not sufficient to handle arbitrary files conforming 54 | to the JPEG specs, but it should handle files that conform to 55 | JFIF or EXIF, as well as files that conform to neither but 56 | have both JFIF and EXIF application segment (which is the majority 57 | of files in existence!). 58 | 59 | When writing out files all segment will be written out in the order 60 | in which they were read. Any 'unknown' segment will be written out 61 | as is. Note: This may or may not corrupt the data. If the segment 62 | format relies on absolute references then this library may still 63 | corrupt that segment! 64 | 65 | 66 | Can have a JpegFile in two modes: Read Only and Read Write. 67 | 68 | Read Only mode: trying to access missing elements will result in 69 | an AttributeError. 70 | 71 | Read Write mode: trying to access missing elements will automatically 72 | create them. 73 | 74 | E.g: 75 | 76 | img.exif.primary. 77 | .geo 78 | .interop 79 | .exif. 80 | .exif.makernote. 81 | 82 | .thumbnail 83 | img.flashpix.<...> 84 | img.jfif. 85 | img.xmp 86 | 87 | E.g: 88 | 89 | try: 90 | print img.exif.tiff.exif.FocalLength 91 | except AttributeError: 92 | print "No Focal Length data" 93 | 94 | """ 95 | 96 | import StringIO 97 | import sys 98 | from struct import unpack, pack 99 | 100 | MAX_HEADER_SIZE = 64 * 1024 101 | DELIM = 0xff 102 | EOI = 0xd9 103 | SOI_MARKER = chr(DELIM) + '\xd8' 104 | EOI_MARKER = chr(DELIM) + '\xd9' 105 | 106 | TIFF_OFFSET = 6 107 | TIFF_TAG = 0x2a 108 | 109 | DEBUG = 0 110 | 111 | # By default, if we find a makernote with an unknown format, we 112 | # simply skip over it. In some cases, it makes sense to raise a 113 | # real error. 114 | # 115 | # Set to `unknown_make_note_as_error` to True, if errors should 116 | # be raised. 117 | unknown_maker_note_as_error = False 118 | 119 | 120 | def debug(*debug_string): 121 | """Used for print style debugging. Enable by setting the global 122 | DEBUG to 1.""" 123 | if DEBUG: 124 | for each in debug_string: 125 | print each, 126 | print 127 | 128 | 129 | class DefaultSegment: 130 | """DefaultSegment represents a particluar segment of a JPEG file. 131 | This class is instantiated by JpegFile when parsing Jpeg files 132 | and is not intended to be used directly by the programmer. This 133 | base class is used as a default which doesn't know about the internal 134 | structure of the segment. Other classes subclass this to provide 135 | extra information about a particular segment. 136 | """ 137 | 138 | def __init__(self, marker, fd, data, mode): 139 | """The constructor for DefaultSegment takes the marker which 140 | identifies the segments, a file object which is currently positioned 141 | at the end of the segment. This allows any subclasses to potentially 142 | extract extra data from the stream. Data contains the contents of the 143 | segment.""" 144 | self.marker = marker 145 | self.data = data 146 | self.mode = mode 147 | self.fd = fd 148 | self.code = jpeg_markers.get(self.marker, ('Unknown-{}'.format(self.marker), None))[0] 149 | assert mode in ["rw", "ro"] 150 | if self.data is not None: 151 | self.parse_data(data) 152 | 153 | class InvalidSegment(Exception): 154 | """This exception may be raised by sub-classes in cases when they 155 | can't correctly identify the segment.""" 156 | pass 157 | 158 | def write(self, fd): 159 | """This method is called by JpegFile when writing out the file. It 160 | must write out any data in the segment. This shouldn't in general be 161 | overloaded by subclasses, they should instead override the get_data() 162 | method.""" 163 | fd.write('\xff') 164 | fd.write(pack('B', self.marker)) 165 | data = self.get_data() 166 | fd.write(pack('>H', len(data) + 2)) 167 | fd.write(data) 168 | 169 | def get_data(self): 170 | """This method is called by write to generate the data for this segment. 171 | It should be overloaded by subclasses.""" 172 | return self.data 173 | 174 | def parse_data(self, data): 175 | """This method is called be init to parse any data for the segment. It 176 | should be overloaded by subclasses rather than overloading __init__""" 177 | pass 178 | 179 | def dump(self, fd): 180 | """This is called by JpegFile.dump() to output a human readable 181 | representation of the segment. Subclasses should overload this to provide 182 | extra information.""" 183 | print >> fd, " Section: [%5s] Size: %6d" % \ 184 | (jpeg_markers[self.marker][0], len(self.data)) 185 | 186 | 187 | class StartOfScanSegment(DefaultSegment): 188 | """The StartOfScan segment needs to be treated specially as the actual 189 | image data directly follows this segment, and that data is not included 190 | in the size as reported in the segment header. This instances of this class 191 | are created by JpegFile and it should not be subclassed. 192 | """ 193 | def __init__(self, marker, fd, data, mode): 194 | DefaultSegment.__init__(self, marker, fd, data, mode) 195 | # For SOS we also pull out the actual data 196 | img_data = fd.read() 197 | 198 | # Usually the EOI marker will be at the end of the file, 199 | # optimise for this case 200 | if img_data[-2:] == EOI_MARKER: 201 | remaining = 2 202 | else: 203 | # We need to search 204 | for i in range(len(img_data) - 2): 205 | if img_data[i:i + 2] == EOI_MARKER: 206 | break 207 | else: 208 | raise JpegFile.InvalidFile("Unable to find EOI marker.") 209 | remaining = len(img_data) - i 210 | 211 | self.img_data = img_data[:-remaining] 212 | fd.seek(-remaining, 1) 213 | 214 | def write(self, fd): 215 | """Write segment data to a given file object""" 216 | DefaultSegment.write(self, fd) 217 | fd.write(self.img_data) 218 | 219 | def dump(self, fd): 220 | """Dump as ascii readable data to a given file object""" 221 | print >> fd, " Section: [ SOS] Size: %6d Image data size: %6d" % \ 222 | (len(self.data), len(self.img_data)) 223 | 224 | 225 | class ExifType: 226 | """The ExifType class encapsulates the data types used 227 | in the Exif spec. These should really be called TIFF types 228 | probably. This could be replaced by named tuples in python 2.6.""" 229 | lookup = {} 230 | 231 | def __init__(self, type_id, name, size): 232 | """Create an ExifType with a given name, size and type_id""" 233 | self.id = type_id 234 | self.name = name 235 | self.size = size 236 | ExifType.lookup[type_id] = self 237 | 238 | BYTE = ExifType(1, "byte", 1).id 239 | ASCII = ExifType(2, "ascii", 1).id 240 | SHORT = ExifType(3, "short", 2).id 241 | LONG = ExifType(4, "long", 4).id 242 | RATIONAL = ExifType(5, "rational", 8).id 243 | UNDEFINED = ExifType(7, "undefined", 1).id 244 | SLONG = ExifType(9, "slong", 4).id 245 | SRATIONAL = ExifType(10, "srational", 8).id 246 | 247 | 248 | def exif_type_size(exif_type): 249 | """Return the size of a type""" 250 | return ExifType.lookup.get(exif_type).size 251 | 252 | 253 | class Rational: 254 | """A simple fraction class. Python 2.6 could use the inbuilt Fraction class.""" 255 | 256 | def __init__(self, num, den): 257 | """Create a number fraction num/den.""" 258 | self.num = num 259 | self.den = den 260 | 261 | def __repr__(self): 262 | """Return a string representation of the fraction.""" 263 | return "%s / %s" % (self.num, self.den) 264 | 265 | def as_tuple(self): 266 | """Return the fraction a numerator, denominator tuple.""" 267 | return (self.num, self.den) 268 | 269 | 270 | class IfdData(object): 271 | """Base class for IFD""" 272 | 273 | name = "Generic Ifd" 274 | tags = {} 275 | embedded_tags = {} 276 | 277 | def special_handler(self, tag, data): 278 | """special_handler method can be over-ridden by subclasses 279 | to specially handle the conversion of tags from raw format 280 | into Python data types.""" 281 | pass 282 | 283 | def ifd_handler(self, data): 284 | """ifd_handler method can be over-ridden by subclasses to 285 | specially handle conversion of the Ifd as a whole into a 286 | suitable python representation.""" 287 | pass 288 | 289 | def extra_ifd_data(self, offset): 290 | """extra_ifd_data method can be over-ridden by subclasses 291 | to specially handle conversion of the Python Ifd representation 292 | back into a byte stream.""" 293 | return "" 294 | 295 | def has_key(self, key): 296 | return self[key] is not None 297 | 298 | def __setattr__(self, name, value): 299 | for key, entry in self.tags.items(): 300 | if entry[1] == name: 301 | self[key] = value 302 | return 303 | 304 | for key, entry in self.embedded_tags.items(): 305 | if entry[0] == name: 306 | if not isinstance(value, entry[1]): 307 | raise TypeError("Values assigned to '{}' must be instances of {}".format(entry[0], entry[1])) 308 | self[key] = value 309 | return 310 | 311 | raise AttributeError("Invalid attribute '{}'".format(name)) 312 | 313 | def __delattr__(self, name): 314 | for key, entry in self.tags.items(): 315 | if entry[1] == name: 316 | del self[key] 317 | break 318 | else: 319 | raise AttributeError("Invalid attribute '{}'".format(name)) 320 | 321 | def __getattr__(self, name): 322 | for key, entry in self.tags.items(): 323 | if entry[1] == name: 324 | x = self[key] 325 | if x is None: 326 | raise AttributeError 327 | return x 328 | for key, entry in self.embedded_tags.items(): 329 | if entry[0] == name: 330 | if self.has_key(key): 331 | return self[key] 332 | else: 333 | if self.mode == "rw": 334 | new = entry[1](self.e, 0, "rw", self.exif_file) 335 | self[key] = new 336 | return new 337 | else: 338 | raise AttributeError 339 | raise AttributeError("%s not found.. %s" % (name, self.embedded_tags)) 340 | 341 | def __getitem__(self, key): 342 | if isinstance(key, str): 343 | try: 344 | return self.__getattr__(key) 345 | except AttributeError: 346 | return None 347 | for entry in self.entries: 348 | if key == entry[0]: 349 | if entry[1] == ASCII and not entry[2] is None: 350 | return entry[2].strip('\0') 351 | else: 352 | return entry[2] 353 | return None 354 | 355 | def __delitem__(self, key): 356 | if isinstance(key, str): 357 | try: 358 | return self.__delattr__(key) 359 | except AttributeError: 360 | return None 361 | for entry in self.entries: 362 | if key == entry[0]: 363 | self.entries.remove(entry) 364 | 365 | def __setitem__(self, key, value): 366 | if isinstance(key, str): 367 | return self.__setattr__(key, value) 368 | found = 0 369 | if len(self.tags[key]) < 3: 370 | msg = "Error: Tags aren't set up correctly. Tag: {:x}:{} should have tag type." 371 | raise Exception(msg.format(key, self.tags[key])) 372 | if self.tags[key][2] == ASCII: 373 | if value is not None and not value.endswith('\0'): 374 | value = value + '\0' 375 | for i in range(len(self.entries)): 376 | if key == self.entries[i][0]: 377 | found = 1 378 | entry = list(self.entries[i]) 379 | if value is None: 380 | del self.entries[i] 381 | else: 382 | entry[2] = value 383 | self.entries[i] = tuple(entry) 384 | break 385 | if not found: 386 | # Find type... 387 | # Not quite enough yet... 388 | self.entries.append((key, self.tags[key][2], value)) 389 | return 390 | 391 | def __init__(self, e, offset, exif_file, mode, data=None): 392 | object.__setattr__(self, 'exif_file', exif_file) 393 | object.__setattr__(self, 'mode', mode) 394 | object.__setattr__(self, 'e', e) 395 | object.__setattr__(self, 'entries', []) 396 | 397 | if data is None: 398 | return 399 | 400 | num_entries = unpack(e + 'H', data[offset:offset+2])[0] 401 | next = unpack(e + "I", data[offset+2+12*num_entries: 402 | offset+2+12*num_entries+4])[0] 403 | debug("OFFSET %s - %s" % (offset, next)) 404 | 405 | for i in range(num_entries): 406 | start = (i * 12) + 2 + offset 407 | debug("START: ", start) 408 | entry = unpack(e + "HHII", data[start:start+12]) 409 | tag, exif_type, components, the_data = entry 410 | 411 | debug("%s %s %s %s %s" % (hex(tag), exif_type, 412 | exif_type_size(exif_type), components, 413 | the_data)) 414 | byte_size = exif_type_size(exif_type) * components 415 | 416 | if tag in self.embedded_tags: 417 | try: 418 | actual_data = self.embedded_tags[tag][1](e, the_data, exif_file, self.mode, data) 419 | except JpegFile.SkipTag as exc: 420 | # If the tag couldn't be parsed, and raised 'SkipTag' 421 | # then we just continue. 422 | continue 423 | else: 424 | if byte_size > 4: 425 | debug(" ...offset %s" % the_data) 426 | the_data = data[the_data:the_data+byte_size] 427 | else: 428 | the_data = data[start+8:start+8+byte_size] 429 | 430 | if exif_type == BYTE or exif_type == UNDEFINED: 431 | actual_data = list(the_data) 432 | elif exif_type == ASCII: 433 | if the_data[-1] != '\0': 434 | actual_data = the_data + '\0' 435 | # raise JpegFile.InvalidFile("ASCII tag '%s' not 436 | # NULL-terminated: %s [%s]" % (self.tags.get(tag, 437 | # (hex(tag), 0))[0], the_data, map(ord, the_data))) 438 | # print "ASCII tag '%s' not NULL-terminated: 439 | # %s [%s]" % (self.tags.get(tag, (hex(tag), 0))[0], 440 | # the_data, map(ord, the_data)) 441 | actual_data = the_data 442 | elif exif_type == SHORT: 443 | actual_data = list(unpack(e + ("H" * components), the_data)) 444 | elif exif_type == LONG: 445 | actual_data = list(unpack(e + ("I" * components), the_data)) 446 | elif exif_type == SLONG: 447 | actual_data = list(unpack(e + ("i" * components), the_data)) 448 | elif exif_type == RATIONAL or exif_type == SRATIONAL: 449 | t = 'II' if exif_type == RATIONAL else 'ii' 450 | actual_data = [] 451 | for i in range(components): 452 | actual_data.append(Rational(*unpack(e + t, 453 | the_data[i*8: 454 | i*8+8]))) 455 | else: 456 | raise "Can't handle this" 457 | 458 | if (byte_size > 4): 459 | debug("%s" % actual_data) 460 | 461 | self.special_handler(tag, actual_data) 462 | entry = (tag, exif_type, actual_data) 463 | self.entries.append(entry) 464 | 465 | debug("%-40s %-10s %6d %s" % (self.tags.get(tag, (hex(tag), 0))[0], 466 | ExifType.lookup[exif_type], 467 | components, actual_data)) 468 | self.ifd_handler(data) 469 | 470 | def isifd(self, other): 471 | """Return true if other is an IFD""" 472 | return issubclass(other.__class__, IfdData) 473 | 474 | def getdata(self, e, offset, last=0): 475 | data_offset = offset+2+len(self.entries)*12+4 476 | output_data = "" 477 | 478 | out_entries = [] 479 | 480 | # Add any specifc data for the particular type 481 | extra_data = self.extra_ifd_data(data_offset) 482 | data_offset += len(extra_data) 483 | output_data += extra_data 484 | 485 | for tag, exif_type, the_data in self.entries: 486 | magic_type = exif_type 487 | if (self.isifd(the_data)): 488 | debug("-> Magic..") 489 | sub_data, next_offset = the_data.getdata(e, data_offset, 1) 490 | the_data = [data_offset] 491 | debug("<- Magic", next_offset, data_offset, len(sub_data), 492 | data_offset + len(sub_data)) 493 | data_offset += len(sub_data) 494 | assert(next_offset == data_offset) 495 | output_data += sub_data 496 | magic_type = exif_type 497 | if exif_type != 4: 498 | magic_components = len(sub_data) 499 | else: 500 | magic_components = 1 501 | exif_type = 4 # LONG 502 | byte_size = 4 503 | components = 1 504 | else: 505 | magic_components = components = len(the_data) 506 | byte_size = exif_type_size(exif_type) * components 507 | 508 | if exif_type == BYTE or exif_type == UNDEFINED: 509 | actual_data = "".join(the_data) 510 | elif exif_type == ASCII: 511 | actual_data = the_data 512 | elif exif_type == SHORT: 513 | actual_data = pack(e + ("H" * components), *the_data) 514 | elif exif_type == LONG: 515 | actual_data = pack(e + ("I" * components), *the_data) 516 | elif exif_type == SLONG: 517 | actual_data = pack(e + ("i" * components), *the_data) 518 | elif exif_type == RATIONAL or exif_type == SRATIONAL: 519 | t = 'II' if exif_type == RATIONAL else 'ii' 520 | actual_data = "" 521 | for i in range(components): 522 | actual_data += pack(e + t, *the_data[i].as_tuple()) 523 | else: 524 | raise "Can't handle this", exif_type 525 | if (byte_size) > 4: 526 | output_data += actual_data 527 | actual_data = pack(e + "I", data_offset) 528 | data_offset += byte_size 529 | else: 530 | actual_data = actual_data + '\0' * (4 - len(actual_data)) 531 | out_entries.append((tag, magic_type, 532 | magic_components, actual_data)) 533 | 534 | data = pack(e + 'H', len(self.entries)) 535 | for entry in out_entries: 536 | data += pack(self.e + "HHI", *entry[:3]) 537 | data += entry[3] 538 | 539 | next_offset = data_offset 540 | if last: 541 | data += pack(self.e + "I", 0) 542 | else: 543 | data += pack(self.e + "I", next_offset) 544 | data += output_data 545 | 546 | assert (next_offset == offset+len(data)) 547 | 548 | return data, next_offset 549 | 550 | def dump(self, f, indent=""): 551 | """Dump the IFD file""" 552 | print >> f, indent + "<--- %s start --->" % self.name 553 | for entry in self.entries: 554 | tag, exif_type, data = entry 555 | if exif_type == ASCII: 556 | data = data.strip('\0') 557 | if (self.isifd(data)): 558 | data.dump(f, indent + " ") 559 | else: 560 | if data and len(data) == 1: 561 | data = data[0] 562 | print >> f, indent + " %-40s %s" % \ 563 | (self.tags.get(tag, (hex(tag), 0))[0], data) 564 | print >> f, indent + "<--- %s end --->" % self.name 565 | 566 | 567 | class IfdInterop(IfdData): 568 | name = "Interop" 569 | tags = { 570 | # Interop stuff 571 | 0x0001: ("Interoperability index", "InteroperabilityIndex"), 572 | 0x0002: ("Interoperability version", "InteroperabilityVersion"), 573 | 0x1000: ("Related image file format", "RelatedImageFileFormat"), 574 | 0x1001: ("Related image file width", "RelatedImageFileWidth"), 575 | 0x1002: ("Related image file length", "RelatedImageFileLength"), 576 | } 577 | 578 | 579 | class CanonIFD(IfdData): 580 | tags = { 581 | 0x0006: ("Image Type", "ImageType"), 582 | 0x0007: ("Firmware Revision", "FirmwareRevision"), 583 | 0x0008: ("Image Number", "ImageNumber"), 584 | 0x0009: ("Owner Name", "OwnerName"), 585 | 0x000c: ("Camera serial number", "SerialNumber"), 586 | 0x000f: ("Customer functions", "CustomerFunctions") 587 | } 588 | name = "Canon" 589 | 590 | 591 | class FujiIFD(IfdData): 592 | tags = { 593 | 0x0000: ("Note version", "NoteVersion"), 594 | 0x1000: ("Quality", "Quality"), 595 | 0x1001: ("Sharpness", "Sharpness"), 596 | 0x1002: ("White balance", "WhiteBalance"), 597 | 0x1003: ("Color", "Color"), 598 | 0x1004: ("Tone", "Tone"), 599 | 0x1010: ("Flash mode", "FlashMode"), 600 | 0x1011: ("Flash strength", "FlashStrength"), 601 | 0x1020: ("Macro", "Macro"), 602 | 0x1021: ("Focus mode", "FocusMode"), 603 | 0x1030: ("Slow sync", "SlowSync"), 604 | 0x1031: ("Picture mode", "PictureMode"), 605 | 0x1100: ("Motor or bracket", "MotorOrBracket"), 606 | 0x1101: ("Sequence number", "SequenceNumber"), 607 | 0x1210: ("FinePix Color", "FinePixColor"), 608 | 0x1300: ("Blur warning", "BlurWarning"), 609 | 0x1301: ("Focus warning", "FocusWarning"), 610 | 0x1302: ("AE warning", "AEWarning") 611 | } 612 | name = "FujiFilm" 613 | 614 | def getdata(self, e, offset, last=0): 615 | pre_data = "FUJIFILM" 616 | pre_data += pack(". Got <%s>." % header) 637 | # The it has its own offset 638 | ifd_offset = unpack(", " 868 | "expecting " % exif) 869 | 870 | tiff_data = data[TIFF_OFFSET:] 871 | data = None # Don't need or want data for now on. 872 | 873 | self.tiff_endian = tiff_data[:2] 874 | if self.tiff_endian == "II": 875 | self.e = "<" 876 | elif self.tiff_endian == "MM": 877 | self.e = ">" 878 | else: 879 | raise JpegFile.InvalidFile("Bad TIFF endian header. Got <%s>, " 880 | "expecting or " % 881 | self.tiff_endian) 882 | 883 | tiff_tag, tiff_offset = unpack(self.e + 'HI', tiff_data[2:8]) 884 | 885 | if (tiff_tag != TIFF_TAG): 886 | raise JpegFile.InvalidFile("Bad TIFF tag. Got <%x>, expecting " 887 | "<%x>" % (tiff_tag, TIFF_TAG)) 888 | 889 | # Ok, the header parse out OK. Now we parse the IFDs contained in 890 | # the APP1 header. 891 | 892 | # We use this loop, even though we can really only expect and support 893 | # two IFDs, the Attribute data and the Thumbnail data 894 | offset = tiff_offset 895 | count = 0 896 | 897 | while offset: 898 | count += 1 899 | num_entries = unpack(self.e + 'H', tiff_data[offset:offset+2])[0] 900 | start = 2 + offset + (num_entries*12) 901 | if (count == 1): 902 | ifd = IfdTIFF(self.e, offset, self, self.mode, tiff_data) 903 | elif (count == 2): 904 | ifd = IfdThumbnail(self.e, offset, self, self.mode, tiff_data) 905 | else: 906 | raise JpegFile.InvalidFile() 907 | self.ifds.append(ifd) 908 | 909 | # Get next offset 910 | offset = unpack(self.e + "I", tiff_data[start:start+4])[0] 911 | 912 | def dump(self, fd): 913 | print >> fd, " Section: [ EXIF] Size: %6d" % (len(self.data)) 914 | for ifd in self.ifds: 915 | ifd.dump(fd) 916 | 917 | def get_data(self): 918 | ifds_data = "" 919 | next_offset = 8 920 | for ifd in self.ifds: 921 | debug("OUT IFD") 922 | new_data, next_offset = ifd.getdata(self.e, next_offset, 923 | ifd == self.ifds[-1]) 924 | ifds_data += new_data 925 | 926 | data = "" 927 | data += "Exif\0\0" 928 | data += self.tiff_endian 929 | data += pack(self.e + "HI", 42, 8) 930 | data += ifds_data 931 | 932 | return data 933 | 934 | def get_primary(self, create=False): 935 | """Return the attributes image file descriptor. If it doesn't 936 | exist return None, unless create is True in which case a new 937 | descriptor is created.""" 938 | if len(self.ifds) > 0: 939 | return self.ifds[0] 940 | else: 941 | if create: 942 | assert self.mode == "rw" 943 | new_ifd = IfdTIFF(self.e, None, self, "rw") 944 | self.ifds.insert(0, new_ifd) 945 | return new_ifd 946 | else: 947 | return None 948 | 949 | def _get_property(self): 950 | if self.mode == "rw": 951 | return self.get_primary(True) 952 | else: 953 | primary = self.get_primary() 954 | if primary is None: 955 | raise AttributeError 956 | return primary 957 | 958 | primary = property(_get_property) 959 | 960 | jpeg_markers = { 961 | 0xc0: ("SOF0", []), 962 | 0xc2: ("SOF2", []), 963 | 0xc4: ("DHT", []), 964 | 965 | 0xda: ("SOS", [StartOfScanSegment]), 966 | 0xdb: ("DQT", []), 967 | 0xdd: ("DRI", []), 968 | 969 | 0xe0: ("APP0", []), 970 | 0xe1: ("APP1", [ExifSegment]), 971 | 0xe2: ("APP2", []), 972 | 0xe3: ("APP3", []), 973 | 0xe4: ("APP4", []), 974 | 0xe5: ("APP5", []), 975 | 0xe6: ("APP6", []), 976 | 0xe7: ("APP7", []), 977 | 0xe8: ("APP8", []), 978 | 0xe9: ("APP9", []), 979 | 0xea: ("APP10", []), 980 | 0xeb: ("APP11", []), 981 | 0xec: ("APP12", []), 982 | 0xed: ("APP13", []), 983 | 0xee: ("APP14", []), 984 | 0xef: ("APP15", []), 985 | 986 | 0xfe: ("COM", []), 987 | } 988 | 989 | APP1 = 0xe1 990 | 991 | 992 | class JpegFile: 993 | """JpegFile object. You should create this using one of the static methods 994 | fromFile, fromString or fromFd. The JpegFile object allows you to examine and 995 | modify the contents of the file. To write out the data use one of the methods 996 | writeFile, writeString or writeFd. To get an ASCII dump of the data in a file 997 | use the dump method.""" 998 | 999 | def fromFile(filename, mode="rw"): 1000 | """Return a new JpegFile object from a given filename.""" 1001 | with open(filename, "rb") as f: 1002 | return JpegFile(f, filename=filename, mode=mode) 1003 | fromFile = staticmethod(fromFile) 1004 | 1005 | def fromString(str, mode="rw"): 1006 | """Return a new JpegFile object taking data from a string.""" 1007 | return JpegFile(StringIO.StringIO(str), "from buffer", mode=mode) 1008 | fromString = staticmethod(fromString) 1009 | 1010 | def fromFd(fd, mode="rw"): 1011 | """Return a new JpegFile object taking data from a file object.""" 1012 | return JpegFile(fd, "fd <%d>" % fd.fileno(), mode=mode) 1013 | fromFd = staticmethod(fromFd) 1014 | 1015 | class SkipTag(Exception): 1016 | """This exception is raised if a give tag should be skipped.""" 1017 | pass 1018 | 1019 | class InvalidFile(Exception): 1020 | """This exception is raised if a given file is not able to be parsed.""" 1021 | pass 1022 | 1023 | class NoSection(Exception): 1024 | """This exception is raised if a section is unable to be found.""" 1025 | pass 1026 | 1027 | def __init__(self, input, filename=None, mode="rw"): 1028 | """JpegFile Constructor. input is a file object, and filename 1029 | is a string used to name the file. (filename is used only for 1030 | display functions). You shouldn't use this function directly, 1031 | but rather call one of the static methods fromFile, fromString 1032 | or fromFd.""" 1033 | self.filename = filename 1034 | self.mode = mode 1035 | # input is the file descriptor 1036 | soi_marker = input.read(len(SOI_MARKER)) 1037 | 1038 | # The very first thing should be a start of image marker 1039 | if (soi_marker != SOI_MARKER): 1040 | raise self.InvalidFile("Error reading soi_marker. Got <%s> " 1041 | "should be <%s>" % (soi_marker, SOI_MARKER)) 1042 | 1043 | # Now go through and find all the blocks of data 1044 | segments = [] 1045 | while 1: 1046 | head = input.read(2) 1047 | delim, mark = unpack(">BB", head) 1048 | if (delim != DELIM): 1049 | raise self.InvalidFile("Error, expecting delimiter. " 1050 | "Got <%s> should be <%s>" % 1051 | (delim, DELIM)) 1052 | if mark == EOI: 1053 | # Hit end of image marker, game-over! 1054 | break 1055 | head2 = input.read(2) 1056 | size = unpack(">H", head2)[0] 1057 | data = input.read(size-2) 1058 | possible_segment_classes = jpeg_markers[mark][1] + [DefaultSegment] 1059 | # Try and find a valid segment class to handle 1060 | # this data 1061 | for segment_class in possible_segment_classes: 1062 | try: 1063 | # Note: Segment class may modify the input file 1064 | # descriptor. This is expected. 1065 | attempt = segment_class(mark, input, data, self.mode) 1066 | segments.append(attempt) 1067 | break 1068 | except DefaultSegment.InvalidSegment: 1069 | # It wasn't this one so we try the next type. 1070 | # DefaultSegment will always work. 1071 | continue 1072 | 1073 | self._segments = segments 1074 | 1075 | def writeString(self): 1076 | """Write the JpegFile out to a string. Returns a string.""" 1077 | f = StringIO.StringIO() 1078 | self.writeFd(f) 1079 | return f.getvalue() 1080 | 1081 | def writeFile(self, filename): 1082 | """Write the JpegFile out to a file named filename.""" 1083 | output = open(filename, "wb") 1084 | self.writeFd(output) 1085 | 1086 | def writeFd(self, output): 1087 | """Write the JpegFile out on the file object output.""" 1088 | output.write(SOI_MARKER) 1089 | for segment in self._segments: 1090 | segment.write(output) 1091 | output.write(EOI_MARKER) 1092 | 1093 | def dump(self, f=sys.stdout): 1094 | """Write out ASCII representation of the file on a given file 1095 | object. Output default to stdout.""" 1096 | print >> f, "" % self.filename 1097 | for segment in self._segments: 1098 | segment.dump(f) 1099 | 1100 | def get_exif(self, create=False): 1101 | """get_exif returns a ExifSegment if one exists for this file. 1102 | If the file does not have an exif segment and the create is 1103 | false, then return None. If create is true, a new exif segment is 1104 | added to the file and returned.""" 1105 | for segment in self._segments: 1106 | if isinstance(segment, ExifSegment): 1107 | return segment 1108 | if create: 1109 | return self.add_exif() 1110 | else: 1111 | return None 1112 | 1113 | def add_exif(self): 1114 | """add_exif adds a new ExifSegment to a file, and returns 1115 | it. When adding an EXIF segment is will add it at the start of 1116 | the list of segments.""" 1117 | assert self.mode == "rw" 1118 | new_segment = ExifSegment(APP1, None, None, "rw") 1119 | self._segments.insert(0, new_segment) 1120 | return new_segment 1121 | 1122 | def import_exif(self, new_exif): 1123 | """import_exif sets the files exif segment to new_exif. This will replace 1124 | existing EXIF segment if it exists.""" 1125 | for idx, segment in enumerate(self._segments): 1126 | if isinstance(segment, ExifSegment): 1127 | self._segments[idx] = new_exif 1128 | break 1129 | else: 1130 | self._segments.insert(0, new_exif) 1131 | 1132 | def _get_exif(self): 1133 | """Exif Attribute property""" 1134 | if self.mode == "rw": 1135 | return self.get_exif(True) 1136 | else: 1137 | exif = self.get_exif(False) 1138 | if exif is None: 1139 | raise AttributeError 1140 | return exif 1141 | 1142 | exif = property(_get_exif) 1143 | 1144 | def remove_metadata(self, paranoid=True): 1145 | """Remove all metadata segments from the image. 1146 | 1147 | When paranoid is false, the segments APPn and COM will be removed. 1148 | 1149 | When paranoid is true, all segments, except SOF0, SOF2, DHT, SOS, DQT, or DRI 1150 | will be removed. 1151 | """ 1152 | paranoid_keep_list = ['SOF0', 'SOF2', 'DHT', 'SOS', 'DQT', 'DRI'] 1153 | if paranoid: 1154 | self._segments = [seg for seg in self._segments if 1155 | seg.code in paranoid_keep_list] 1156 | else: 1157 | self._segments = [seg for seg in self._segments if 1158 | not (seg.code == 'COM' or seg.code.startswith('APP'))] 1159 | 1160 | def import_metadata(self, other): 1161 | """import_metadata replaces all the meta-data segments in this file 1162 | with segments from another file. 1163 | 1164 | Metadata segments are APPn and COM segments. 1165 | """ 1166 | self.remove_metadata(paranoid=False) 1167 | new_seg = [seg for seg in other._segments if seg.code == 'COM' or seg.code.startswith('APP')] 1168 | self._segments = new_seg + self._segments 1169 | 1170 | def get_geo(self): 1171 | """Return a tuple of (latitude, longitude).""" 1172 | def convert(x): 1173 | (deg, min, sec) = x 1174 | return (float(deg.num) / deg.den) + \ 1175 | (1/60.0 * float(min.num) / min.den) + \ 1176 | (1/3600.0 * float(sec.num) / sec.den) 1177 | if not hasattr(self.exif.primary, 'GPSIFD'): 1178 | raise self.NoSection, "File %s doesn't have a GPS section." % \ 1179 | self.filename 1180 | 1181 | gps = self.exif.primary.GPS 1182 | lat = convert(gps.GPSLatitude) 1183 | lng = convert(gps.GPSLongitude) 1184 | if gps.GPSLatitudeRef == "S": 1185 | lat = -lat 1186 | if gps.GPSLongitudeRef == "W": 1187 | lng = -lng 1188 | 1189 | return lat, lng 1190 | 1191 | SEC_DEN = 50000000 1192 | 1193 | def _parse(val): 1194 | sign = 1 1195 | if val < 0: 1196 | val = -val 1197 | sign = -1 1198 | 1199 | deg = int(val) 1200 | other = (val - deg) * 60 1201 | minutes = int(other) 1202 | secs = (other - minutes) * 60 1203 | secs = long(secs * JpegFile.SEC_DEN) 1204 | return (sign, deg, minutes, secs) 1205 | 1206 | _parse = staticmethod(_parse) 1207 | 1208 | def set_geo(self, lat, lng): 1209 | """Set the GeoLocation to a given lat and lng""" 1210 | if self.mode != "rw": 1211 | raise RWError 1212 | 1213 | gps = self.exif.primary.GPS 1214 | 1215 | sign, deg, min, sec = JpegFile._parse(lat) 1216 | ref = "N" 1217 | if sign < 0: 1218 | ref = "S" 1219 | 1220 | gps.GPSLatitudeRef = ref 1221 | gps.GPSLatitude = [Rational(deg, 1), 1222 | Rational(min, 1), 1223 | Rational(sec, JpegFile.SEC_DEN)] 1224 | 1225 | sign, deg, min, sec = JpegFile._parse(lng) 1226 | ref = "E" 1227 | if sign < 0: 1228 | ref = "W" 1229 | gps.GPSLongitudeRef = ref 1230 | gps.GPSLongitude = [Rational(deg, 1), 1231 | Rational(min, 1), 1232 | Rational(sec, JpegFile.SEC_DEN)] 1233 | --------------------------------------------------------------------------------