File Coverage

blib/lib/Mac/iPhoto/Exif.pm
Criterion Covered Total %
statement 22 24 91.6
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 30 32 93.7


line stmt bran cond sub pod time code
1             # ============================================================================
2             package Mac::iPhoto::Exif;
3             # ============================================================================
4              
5 2     2   135440 use 5.010;
  2         10  
  2         148  
6 2     2   2609 use utf8;
  2         22  
  2         11  
7 2     2   3131 no if $] >= 5.017004, warnings => qw(experimental::smartmatch);
  2         29  
  2         14  
8              
9 2     2   2618 use Moose;
  2         1625710  
  2         21  
10              
11 2     2   37926 use Moose::Util::TypeConstraints;
  2         4  
  2         26  
12 2     2   17767 use Path::Class;
  2         197558  
  2         196  
13 2     2   2914 use Encode;
  2         65305  
  2         314  
14 2     2   3085 use XML::LibXML;
  0            
  0            
15             use File::Copy;
16             use DateTime;
17             use Unicode::Normalize;
18              
19             use Image::ExifTool;
20             use Image::ExifTool::Location;
21              
22             our $VERSION = "1.01";
23             our $AUTHORITY = 'cpan:MAROS';
24              
25             our @LEVELS = qw(debug info warn error);
26             our $DATE_SEPARATOR = '[.:\/]';
27             our $TIMERINTERVAL_EPOCH = 978307200; # Epoch of TimeInterval zero point: 2001.01.01
28             our $IPHOTO_ALBUM = $ENV{HOME}.'/Pictures/iPhoto Library/AlbumData.xml';
29              
30             subtype 'Mac::iPhoto::Exif::Type::Dirs'
31             => as 'ArrayRef[Path::Class::Dir]';
32              
33             subtype 'Mac::iPhoto::Exif::Type::File'
34             => as 'Path::Class::File';
35              
36             coerce 'Mac::iPhoto::Exif::Type::File'
37             => from 'Str'
38             => via { Path::Class::File->new($_) }
39             => from 'ArrayRef[Str]'
40             => via { Path::Class::Dir->new($_->[0]) };
41              
42             coerce 'Mac::iPhoto::Exif::Type::Dirs'
43             => from 'Str'
44             => via { [ Path::Class::Dir->new($_) ] }
45             => from 'ArrayRef[Str]'
46             => via { [ map { Path::Class::Dir->new($_) } @$_ ] };
47              
48             has 'dryrun' => (
49             is => 'ro',
50             isa => 'Bool',
51             default => 0,
52             documentation => 'Dry-run [Default: false]',
53             );
54              
55             has 'directory' => (
56             is => 'ro',
57             isa => 'Mac::iPhoto::Exif::Type::Dirs',
58             coerce => 1,
59             predicate => 'has_directory',
60             documentation => "Limit operation to given directories [Multiple; Default: All]",
61             );
62              
63             has 'exclude' => (
64             is => 'ro',
65             isa => 'Mac::iPhoto::Exif::Type::Dirs',
66             coerce => 1,
67             predicate => 'has_exclude',
68             documentation => "Exclude given directories [Multiple; Default: None]",
69             );
70              
71             has 'iphoto_album' => (
72             is => 'ro',
73             isa => 'Mac::iPhoto::Exif::Type::File',
74             coerce => 1,
75             #default => $IPHOTO_ALBUM,
76             documentation => "Path to iPhoto library [Default: $IPHOTO_ALBUM]",
77             );
78              
79             has 'changetime' => (
80             is => 'ro',
81             isa => 'Bool',
82             documentation => 'Change file time according to exif timestamps [Default: true]',
83             default => 1,
84             );
85              
86             has 'backup' => (
87             is => 'ro',
88             isa => 'Bool',
89             documentation => 'Backup files [Default: false]',
90             default => 0,
91             );
92              
93             has 'nomerge' => (
94             is => 'ro',
95             isa => 'Bool',
96             documentation => 'Do not merge existing exif tags and faces but overwrite [Default: true]',
97             default => 0,
98             );
99              
100             sub log {
101             my ($self,$loglevel,$format,@params) = @_;
102             # DO not log anything
103             return;
104             }
105              
106             sub parse_album {
107             my ($self) = @_;
108            
109             my $parser = XML::LibXML->new(
110             encoding => 'utf-8',
111             no_blanks => 1,
112             );
113            
114             my $doc = eval {
115             $self->log('info','Reading iPhoto album %s',$self->iphoto_album);
116             return $parser->parse_file($self->iphoto_album);
117             };
118             if (! $doc) {
119             $self->log('error','Could not parse iPhoto album: %s',$@ // 'unknown error');
120             die('Cannot continue');
121             }
122             return $doc;
123             }
124              
125              
126             sub run {
127             my ($self) = @_;
128            
129             my $doc = $self->parse_album;
130            
131             my $persons = {};
132             my $keywords = {};
133             my $count = 0;
134             foreach my $top_node ($doc->findnodes('/plist/dict/key')) {
135             given ($top_node->textContent) {
136             when ('List of Faces') {
137             my $personlist_node = $top_node->nextNonBlankSibling();
138             my $persons_hash = _plist_node_to_hash($personlist_node);
139             foreach my $person (values %$persons_hash) {
140             $persons->{$person->{key}} = $person->{name};
141             }
142             $self->log('info','Fetching faces (%i)',scalar(keys %$persons));
143             }
144             when ('List of Keywords') {
145             my $keywordlist_node = $top_node->nextNonBlankSibling();
146             $keywords = _plist_node_to_hash($keywordlist_node);
147             $self->log('info','Fetching keywords (%i)',scalar(keys %$keywords));
148             }
149             when ('Master Image List') {
150             my $imagelist_node = $top_node->nextNonBlankSibling();
151             my $key;
152             IMAGE_NODES:
153             foreach my $image_node ($imagelist_node->childNodes) {
154             given ($image_node->nodeName) {
155             when ('key') {
156             $key = $image_node->textContent;
157             }
158             when ('dict') {
159            
160             my $image = _plist_node_to_value($image_node);
161            
162             my $image_path = Path::Class::File->new($image->{OriginalPath} || $image->{ImagePath});
163            
164             # Check if original image file is present
165             unless (-e $image_path->stringify) {
166             $self->log('error','Could not find image at %s',$image_path->stringify);
167             next IMAGE_NODES;
168             }
169            
170             my $image_directory = $image_path->dir;
171            
172             # Process directories
173             if ($self->has_directory) {
174             my $contains = 0;
175             foreach my $directory (@{$self->directory}) {
176             if ($directory->contains($image_directory)) {
177             $contains = 1;
178             last;
179             }
180             }
181             next IMAGE_NODES
182             unless $contains;
183             }
184            
185             # Process excludes
186             if ($self->has_exclude) {
187             my $contains = 0;
188             foreach my $directory (@{$self->exclude}) {
189             if ($directory->contains($image_directory)) {
190             $contains = 1;
191             last;
192             }
193             }
194             next IMAGE_NODES
195             if $contains;
196             }
197            
198             my $latitude = $image->{latitude};
199             my $longitude = $image->{longitude};
200             my $rating = $image->{Rating};
201             my $comment = $image->{Comment};
202             my $faces = $image->{Faces};
203            
204             $self->log('info','Processing %s',$image_path->stringify);
205             my $exif = Image::ExifTool->new(
206             Charset => 'UTF8',
207             #DateFormat=>undef
208             );
209             $exif->Options(Charset => 'UTF8');
210             #$exif->Options(DateFormat => undef);
211            
212             $exif->ExtractInfo($image_path->stringify);
213            
214             my $date;
215            
216             # Take crazy date form iphoto album?
217             #my $date = $image->{DateAsTimerInterval} + $TIMERINTERVAL_EPOCH;
218            
219             my $date_original = $exif->GetValue('DateTimeOriginal');
220             if (defined $date_original
221             && $date_original =~ m/^
222             (?<year>(19|20)\d{2})
223             $DATE_SEPARATOR
224             (?<month>\d{1,2})
225             $DATE_SEPARATOR
226             (?<day>\d{1,2})
227             \s
228             (?<hour>\d{1,2})
229             $DATE_SEPARATOR
230             (?<minute>\d{1,2})
231             $DATE_SEPARATOR
232             (?<second>\d{1,2})
233             /x) {
234             $date = DateTime->new(
235             (map { $_ => $+{$_} } qw(year month day hour minute second)),
236             time_zone => 'local',
237             );
238             } else {
239             $self->log('error','Could not parse date format %s',$date_original // 'UNDEF');
240             next IMAGE_NODES;
241             }
242            
243             my %keywords = map { $keywords->{$_} => 1 } @{$image->{Keywords}};
244            
245             my $changed_exif = 0;
246            
247             # Faces
248             if (defined $faces && scalar @{$faces}) {
249             my @persons_list_original = grep { Encode::_utf8_on($_); 1; } $exif->GetValue('PersonInImage');
250             my @persons_list_final;
251            
252             unless ($self->nomerge) {
253             foreach my $person (@persons_list_original) {
254             # i probably should not do that, but Image::ExifTools seems to
255             # return utf8 encoded strings without the utf8 flag set
256             Encode::_utf8_on($person);
257            
258             unless ($person ~~ \@persons_list_final) {
259             push(@persons_list_final,$person)
260             }
261             }
262             }
263            
264             FACES:
265             foreach my $face (@$faces) {
266             my $person = $persons->{$face->{'face key'}};
267             next FACES
268             unless defined $person;
269             next FACES
270             if $person ~~ \@persons_list_final;
271             $self->log('debug','- Add person %s',$person)
272             unless $self->nomerge;
273             push(@persons_list_final,$person);
274             }
275            
276             @persons_list_original = sort @persons_list_original;
277             @persons_list_final = sort @persons_list_final;
278            
279             if (_list_is_changed(\@persons_list_final,\@persons_list_original)) {
280             $changed_exif = 1;
281             $self->log('debug','- Set persons %s',join(',',@persons_list_final))
282             if $self->nomerge;
283             $exif->SetNewValue('PersonInImage',[ @persons_list_final ]);
284             }
285             }
286            
287             # Keywords
288             if (scalar keys %keywords) {
289             my @keywords_list_original = grep { Encode::_utf8_on($_); 1; } $exif->GetValue('Keywords');
290             my @keywords_list_final;
291            
292             unless ($self->nomerge) {
293             foreach my $keyword (@keywords_list_original) {
294             # i probably should not do that, but Image::ExifTools seems to
295             # return utf8 encoded strings without the utf8 flag set
296             Encode::_utf8_on($keyword);
297            
298             unless ($keyword ~~ \@keywords_list_final) {
299             push(@keywords_list_final,$keyword)
300             }
301             }
302             }
303            
304             KEYWORDS:
305             foreach my $keyword (keys %keywords) {
306             next KEYWORDS
307             if $keyword ~~ \@keywords_list_final;
308             $self->log('debug','- Add keyword %s',$keyword)
309             unless $self->nomerge;
310             push(@keywords_list_final,$keyword);
311             }
312            
313             @keywords_list_original = sort @keywords_list_original;
314             @keywords_list_final = sort @keywords_list_final;
315            
316             if (_list_is_changed(\@keywords_list_final,\@keywords_list_original)) {
317             $changed_exif = 1;
318             $self->log('debug','- Set keywords %s',join(',',@keywords_list_final))
319             if $self->nomerge;
320             $exif->SetNewValue('Keywords',[ @keywords_list_final ]);
321             }
322             }
323            
324             # User comments
325             if ($comment) {
326             my $old_comment = $exif->GetValue('UserComment');
327             Encode::_utf8_on($old_comment);
328             if (! defined $old_comment
329             || $old_comment ne $comment) {
330             $self->log('debug','- Set user comment');
331             $exif->SetNewValue('UserComment',$comment);
332             $changed_exif = 1;
333             }
334             }
335            
336             # User ratings
337             if ($rating && $rating > 0) {
338             my $old_rating = $exif->GetValue('Rating') // 0;
339             if (! defined $old_rating
340             || $old_rating != $rating) {
341             $self->log('debug','- Set rating %i',$rating);
342             $exif->SetNewValue('Rating',$rating);
343             $changed_exif = 1;
344             }
345             }
346            
347             # Geo Tags
348             if ($latitude && $longitude) {
349             my ($old_latitude,$old_longitude) = $exif->GetLocation($latitude,$longitude);
350             $old_latitude //= 0;
351             $old_longitude //= 0;
352             if (sprintf('%.4f',$latitude) != sprintf('%.4f',$old_latitude)
353             && sprintf('%.4f',$longitude) != sprintf('%.4f',$old_longitude)) {
354             $self->log('debug','- Set geo location %fN,%fS',$latitude,$longitude);
355             $exif->SetLocation($latitude,$longitude);
356             $changed_exif = 1;
357             }
358             }
359            
360             unless ($self->dryrun) {
361             if ($changed_exif) {
362             if ($self->backup) {
363             my $backup_path = Path::Class::File->new($image_path->dir,'_'.$image_path->basename);
364             $self->log('debug','- Writing backup file to %s',$backup_path->stringify);
365             File::Copy::syscopy($image_path->stringify,$backup_path->stringify)
366             or $self->log('error','Could not copy %s to %s: %s',$image_path->stringify,$backup_path->stringify,$!);
367             }
368             my $success = $exif->WriteInfo($image_path->stringify);
369             if ($success) {
370             $self->log('debug','- Exif data has been written to %s',$image_path->stringify);
371             } else {
372             $self->log('error','Could not write to %s: %s',$image_path->stringify,$exif->GetValue('Error'));
373             }
374             }
375            
376             if ($self->changetime) {
377             $self->log('debug','- Change file time to %s',$date->datetime);
378             utime($date->epoch, $date->epoch, $image_path->stringify)
379             or $self->log('error','Could not utime %s: %s',$image_path->stringify,$!);
380             }
381             }
382            
383             $count ++;
384             }
385             }
386             }
387             }
388             }
389             }
390            
391             return 1;
392             }
393              
394              
395              
396             sub _fix_string {
397             my ($string) = @_;
398            
399             if ($string =~ /[[:alpha:]]/) {
400             $string = NFC($string);
401             $string =~ s/\p{NonspacingMark}//g;
402             }
403             return $string;
404             }
405              
406             sub _plist_node_to_hash {
407             my ($node) = @_;
408            
409             my $return = {};
410             my $key;
411             foreach my $child_node ($node->childNodes) {
412             if ($child_node->nodeType == 1) {
413             given ($child_node->nodeName) {
414             when ('key') {
415             $key = $child_node->textContent;
416             }
417             default {
418             $return->{$key} = _plist_node_to_value($child_node);
419             }
420             }
421             }
422             }
423            
424             return $return;
425             }
426              
427             sub _plist_node_to_value {
428             my ($node) = @_;
429             given ($node->nodeName) {
430             when ('string') {
431             return _fix_string($node->textContent);
432             }
433             when ([qw(real integer)]) {
434             return $node->textContent + 0;
435             }
436             when ('array') {
437             return _plist_node_to_array($node);
438             }
439             when ('dict') {
440             return _plist_node_to_hash($node);
441             }
442             }
443            
444             return;
445             }
446              
447             sub _plist_node_to_array {
448             my ($node) = @_;
449            
450             my $return = [];
451             foreach my $child_node ($node->childNodes) {
452             if ($child_node->nodeType == 1) {
453             push (@$return,_plist_node_to_value($child_node));
454             }
455             }
456            
457             return $return;
458             }
459              
460             sub _list_is_changed {
461             my ($list_final,$list_original) = @_;
462            
463             return 1
464             if scalar @$list_final != scalar @$list_original;
465            
466             for (my $index = 0; $index <= scalar @$list_final; $index ++) {
467             return 1
468             unless $list_final->[$index] ~~ $list_original->[$index];
469             }
470             return 0;
471             }
472              
473             __PACKAGE__->meta->make_immutable;
474             no Moose;
475             1;
476              
477             =encoding utf8
478              
479             =head1 NAME
480              
481             Mac::iPhoto::Exif - Write iPhoto meta data to Exif
482              
483             =head1 SYNOPSIS
484              
485             console$ iphoto2exif --directory /data/photo/2010/summer_vacation
486              
487             or
488              
489             use Mac::iPhoto::Exif;
490             my $iphotoexif = Mac::iPhoto::Exif->new(
491             directory => '/data/photo/2010/summer_vacation'
492             );
493             $iphotoexif->run;
494              
495             =head1 DESCRIPTION
496              
497             This module write meta data from the iPhoto database like keywords,
498             geo locations, comments, ratings and faces to the pictures Exif data.
499              
500             The following exif tags are being used:
501              
502             =over
503              
504             =item * PersonInImage
505              
506             =item * Keywords
507              
508             =item * UserComment
509              
510             =item * Rating
511              
512             =item * GPSLatitude, GPSLongitude, GPSLatitudeRef, GPSLongitudeRef
513              
514             =item * Rating
515              
516             =back
517              
518             =head1 ACCESSORS
519              
520             =head2 directory
521              
522             Limit operation to one or more directories.
523              
524             ArrayRef of Path::Class::Dir
525              
526             =head2 exclude
527              
528             Exclude one or more directories.
529              
530             ArrayRef of Path::Class::Dir
531              
532             =head2 iphoto_album
533              
534             Path to the iPhoto AlbumData.xml database.
535              
536             Path::Class::File
537              
538             =head2 loglevel
539              
540             Be more/less verbose.
541              
542             Accepted loglevels are : debug, info, warn and error
543              
544             Default: info
545              
546             =head2 changetime
547              
548             Change file create time according to exif timestamps
549              
550             Default: true
551              
552             =head2 backup
553              
554             Backup changed files
555              
556             Default: false
557              
558             =head2 dryrun
559              
560             Do not alter files, just log actions
561              
562             Default: false
563              
564             =head1 METHODS
565              
566             =head2 parse_album
567              
568             Return the iPhoto album as a XML::LibXml::Doc object
569              
570             =head2 run
571              
572             Run the iPhoto to Exif conversion
573              
574             =head2 log
575              
576             Log message
577              
578             =head1 DISCLAIMER
579              
580             This module has been extensively tested on my machine (OSX 10.6.6,
581             iPhoto 9.1.1) and deemed to work correctly. However I do not guarantee that
582             it will work correctly on any other machine/setup. So make sure that you have
583             backups of your valualble pictures before running this program!
584              
585             THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
586             EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
587             OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. PERFORMANCE OF THE
588             SOFTWARE IS WITH YOU.
589              
590             IN NO EVENT WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
591             AND/OR REDISTRIBUTE THE SOFTWARE AS PERMITTED BY THE ABOVE LICENCE, BE LIABLE
592             TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR
593             CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE SOFTWARE.
594              
595             =head1 SUPPORT
596              
597             Please report any bugs or feature requests to
598             C<mac-iphoto-exif@rt.cpan.org>, or through the web interface at
599             L<http://rt.cpan.org/Public/Bug/Report.html?Queue=Mac::iPhoto::Exif>.
600             I will be notified and then you'll automatically be notified of the progress
601             on your report as I make changes.
602              
603             =head1 AUTHOR
604              
605             MaroÅ¡ Kollár
606             CPAN ID: MAROS
607             maros [at] k-1.com
608            
609             L<http://www.k-1.com>
610              
611             =head1 COPYRIGHT & LICENSE
612              
613             Mac::iPhoto::Exif is Copyright (c) 2009, MaroÅ¡ Kollár
614             - L<http://www.k-1.com>
615              
616             This program is free software; you can redistribute it and/or modify it under
617             the same terms as Perl itself.
618              
619             The full text of the license can be found in the
620             LICENSE file included with this module.
621              
622             =cut