File Coverage

blib/lib/Image/ExifTool/Shift.pl
Criterion Covered Total %
statement 209 255 81.9
branch 108 180 60.0
condition 64 104 61.5
subroutine 8 8 100.0
pod 0 7 0.0
total 389 554 70.2


line stmt bran cond sub pod time code
1             #------------------------------------------------------------------------------
2             # File: Shift.pl
3             #
4             # Description: ExifTool time shifting routines
5             #
6             # Revisions: 10/28/2005 - P. Harvey Created
7             # 03/13/2019 - PH Added single-argument form of ShiftTime()
8             #------------------------------------------------------------------------------
9              
10             package Image::ExifTool;
11              
12 3     3   75 use strict;
  3         8  
  3         11944  
13              
14             sub ShiftTime($;$$$);
15              
16             #------------------------------------------------------------------------------
17             # apply shift to value in new value hash
18             # Inputs: 0) ExifTool ref, 1) shift type, 2) shift string, 3) raw date/time value,
19             # 4) new value hash ref
20             # Returns: error string or undef on success and updates value in new value hash
21             sub ApplyShift($$$$;$)
22             {
23 13     13 0 58 my ($self, $func, $shift, $val, $nvHash) = @_;
24              
25             # get shift direction from first character in shift string
26 13 100       148 my $pre = ($shift =~ s/^(\+|-)//) ? $1 : '+';
27 13 100       61 my $dir = ($pre eq '+') ? 1 : -1;
28 13         37 my $tagInfo = $$nvHash{TagInfo};
29 13         44 my $tag = $$tagInfo{Name};
30 13         29 my $shiftOffset;
31 13 50       52 if ($$nvHash{ShiftOffset}) {
32 0         0 $shiftOffset = $$nvHash{ShiftOffset};
33             } else {
34 13         58 $shiftOffset = $$nvHash{ShiftOffset} = { };
35             }
36              
37             # initialize handler for eval warnings
38 13         94 local $SIG{'__WARN__'} = \&SetWarning;
39 13         64 SetWarning(undef);
40              
41             # shift is applied to ValueConv value, so we must ValueConv-Shift-ValueConvInv
42 13         31 my ($type, $err);
43 13         40 foreach $type ('ValueConv','Shift','ValueConvInv') {
44 39 100       166 if ($type eq 'Shift') {
    100          
45             #### eval ShiftXxx function
46 13         1413 $err = eval "Shift$func(\$val, \$shift, \$dir, \$shiftOffset)";
47             } elsif ($$tagInfo{$type}) {
48 4         14 my $conv = $$tagInfo{$type};
49 4 50       15 if (ref $conv eq 'CODE') {
50 0         0 $val = &$conv($val, $self);
51             } else {
52 4 50       15 return "Can't handle $type for $tag in ApplyShift()" if ref $$tagInfo{$type};
53             #### eval ValueConv/ValueConvInv ($val, $self)
54 4         379 $val = eval $$tagInfo{$type};
55             }
56             } else {
57 22         60 next;
58             }
59             # handle errors
60 17 50       106 $err and return $err;
61 17 50       65 $@ and SetWarning($@);
62 17 50       75 GetWarning() and return CleanWarning();
63             }
64             # update value in new value hash
65 13         77 $nvHash->{Value} = [ $val ];
66 13         102 return undef; # success
67             }
68              
69             #------------------------------------------------------------------------------
70             # Check date/time shift
71             # Inputs: 0) shift type, 1) shift string (without sign)
72             # Returns: updated shift string, or undef on error (and may update shift)
73             sub CheckShift($$)
74             {
75 51     51 0 3880 my ($type, $shift) = @_;
76 51         90 my $err;
77 51 50       159 if ($type eq 'Time') {
78 51 50       457 return "No shift direction" unless $shift =~ s/^(\+|-)//;
79             # do a test shift to validate the shift string
80 51         133 my $testTime = '2005:11:02 09:00:13.25-04:00';
81 51 100       304 $err = ShiftTime($testTime, $shift, $1 eq '+' ? 1 : -1);
82             } else {
83 0         0 $err = "Unknown shift type ($type)";
84             }
85 51         216 return $err;
86             }
87              
88             #------------------------------------------------------------------------------
89             # return the number of days in a month
90             # Inputs: 0) month number (Jan=1, may be outside range), 1) year
91             # Returns: number of days in month
92             sub DaysInMonth($$)
93             {
94 49     49 0 97 my ($mon, $year) = @_;
95 49         113 my @days = (31,28,31,30,31,30,31,31,30,31,30,31);
96             # adjust to the range [0,11]
97 49         125 while ($mon < 1) { $mon += 12; --$year; }
  3         25  
  3         8  
98 49         114 while ($mon > 12) { $mon -= 12; ++$year; }
  0         0  
  0         0  
99             # return standard number of days unless february on a leap year
100 49 50 66     233 return $days[$mon-1] unless $mon == 2 and not $year % 4;
101             # leap years don't occur on even centuries except every 400 years
102 0 0 0     0 return 29 if $year % 100 or not $year % 400;
103 0         0 return 28;
104             }
105              
106             #------------------------------------------------------------------------------
107             # split times into corresponding components: YYYY mm dd HH MM SS tzh tzm
108             # Inputs: 0) date/time or shift string 1) reference to list for returned components
109             # 2) optional reference to list of time components (if shift string)
110             # Returns: true on success
111             # Returned components are 0-Y, 1-M, 2-D, 3-hr, 4-min, 5-sec, 6-tzhr, 7-tzmin
112             sub SplitTime($$;$)
113             {
114 126     126 0 383 my ($val, $vals, $time) = @_;
115             # insert zeros if missing in shift string
116 126 100       382 if ($time) {
117 60         296 $val =~ s/(^|[-+:\s]):/${1}0:/g;
118 60         250 $val =~ s/:([:\s]|$)/:0$1/g;
119             }
120             # change dashes to colons in date (for XMP dates)
121 126 50       440 if ($val =~ s/^(\d{4})-(\d{2})-(\d{2})/$1:$2:$3/) {
122 0         0 $val =~ tr/T/ /; # change 'T' separator to ' '
123             }
124             # add space before timezone to split it into a separate word
125 126         876 $val =~ s/(\+|-)/ $1/;
126 126         491 my @words = split ' ', $val;
127 126         259 my $err = 1;
128 126         226 my @v;
129 126         260 for (;;) {
130 386         727 my $word = shift @words;
131 386 100       1035 last unless defined $word;
132             # split word into separate numbers (allow decimal points but no signs)
133 260 50       2486 my @vals = $word =~ /(?=\d|\.\d)\d*(?:\.\d*)?/g or last;
134 260 100 100     2533 if ($word =~ /^(\+|-)/) {
    100 100        
      100        
      100        
      100        
      66        
135             # this is the timezone
136 54 50 33     285 (defined $v[6] or @vals > 2) and $err = 1, last;
137 54 100       218 my $sign = ($1 ne '-') ? 1 : -1;
138             # apply sign to both minutes and seconds
139 54         197 $v[6] = $sign * shift(@vals);
140 54   50     190 $v[7] = $sign * (shift(@vals) || 0);
141             } elsif ((@words and $words[0] =~ /^\d+/) or # there is a time word to follow
142             (not $time and $vals[0] =~ /^\d{3}/) or # first value is year (3 or more digits)
143             ($time and not defined $$time[3] and not defined $v[0])) # we don't have a time
144             {
145             # this is a date (must come first)
146 83 50 33     433 (@v or @vals > 3) and $err = 1, last;
147 83 50 66     407 not $time and @vals != 3 and $err = 1, last;
148 83         214 $v[2] = pop(@vals); # take day first if only one specified
149 83   100     283 $v[1] = pop(@vals) || 0;
150 83   100     283 $v[0] = pop(@vals) || 0;
151             } else {
152             # this is a time (can't come after timezone)
153 123 50 33     796 (defined $v[3] or defined $v[6] or @vals > 3) and $err = 1, last;
      33        
154 123 50 66     595 not $time and @vals != 3 and @vals != 2 and $err = 1, last;
      33        
155 123         274 $v[3] = shift(@vals); # take hour first if only one specified
156 123   100     383 $v[4] = shift(@vals) || 0;
157 123   100     405 $v[5] = shift(@vals) || 0;
158             }
159 260         538 $err = 0;
160             }
161 126 50 33     596 return 0 if $err or not @v;
162 126 100       336 if ($time) {
163             # zero any required shift entries which aren't yet defined
164 60 100 66     338 $v[0] = $v[1] = $v[2] = 0 if defined $$time[0] and not defined $v[0];
165 60 50 66     279 $v[3] = $v[4] = $v[5] = 0 if defined $$time[3] and not defined $v[3];
166 60 100 66     284 $v[6] = $v[7] = 0 if defined $$time[6] and not defined $v[6];
167             }
168 126         574 @$vals = @v; # return split time components
169 126         659 return 1;
170             }
171              
172             #------------------------------------------------------------------------------
173             # shift date/time by components
174             # Inputs: 0) split date/time list ref, 1) split shift list ref,
175             # 2) shift direction, 3) reference to output list of shifted components
176             # 4) number of decimal points in seconds
177             # 5) reference to return time difference due to rounding
178             # Returns: error string or undef on success
179             sub ShiftComponents($$$$$;$)
180             {
181 60     60 0 207 my ($time, $shift, $dir, $toTime, $dec, $rndPt) = @_;
182             # min/max for Y, M, D, h, m, s
183 60         193 my @min = ( 0, 1, 1, 0, 0, 0);
184 60         3817 my @max = (10000,12,28,24,60,60);
185 60         132 my $i;
186             #
187             # apply the shift
188             #
189 60         139 my $c = 0;
190 60         209 for ($i=0; $i<@$time; ++$i) {
191 462   100     2269 my $v = ($$time[$i] || 0) + $dir * ($$shift[$i] || 0) + $c;
      100        
192             # handle fractional values by propagating remainders downwards
193 462 50 66     1282 if ($v != int($v) and $i < 5) {
194 0         0 my $iv = int($v);
195 0         0 $c = ($v - $iv) * $max[$i+1];
196 0         0 $v = $iv;
197             } else {
198 462         744 $c = 0;
199             }
200 462         1289 $$toTime[$i] = $v;
201             }
202             # round off seconds to the required number of decimal points
203 60         144 my $sec = $$toTime[5];
204 60 100 100     380 if (defined $sec and $sec != int($sec)) {
205 51         150 my $mult = 10 ** $dec;
206 51         167 my $rndSec = int($sec * $mult + 0.5 * ($sec <=> 0)) / $mult;
207 51 50       166 $rndPt and $$rndPt = $sec - $rndSec;
208 51         125 $$toTime[5] = $rndSec;
209             }
210             #
211             # handle overflows, starting with least significant number first (seconds)
212             #
213 60         157 $c = 0;
214 60         189 for ($i=5; $i>=0; $i--) {
215 360 100       859 defined $$time[$i] or $c = 0, next;
216             # apply shift and adjust for previous overflow
217 354         666 my $v = $$toTime[$i] + $c;
218 354         560 $c = 0; # set carry to zero
219             # adjust for over/underflow
220 354         786 my ($min, $max) = ($min[$i], $max[$i]);
221 354 100       1000 if ($v < $min) {
    50          
222 35 100       117 if ($i == 2) { # 2 = day of month
223 7         14 do {
224             # add number of days in previous month
225 49         72 --$c;
226 49         98 my $mon = $$toTime[$i-1] + $c;
227 49         113 $v += DaysInMonth($mon, $$toTime[$i-2]);
228             } while ($v < 1);
229             } else {
230 28         72 my $fc = ($v - $min) / $max;
231             # carry ($c) must be largest integer equal to or less than $fc
232 28         64 $c = int($fc);
233 28 50       101 --$c if $c > $fc;
234 28         64 $v -= $c * $max;
235             }
236             } elsif ($v >= $max + $min) {
237 0 0       0 if ($i == 2) {
238 0         0 for (;;) {
239             # test against number of days in current month
240 0         0 my $mon = $$toTime[$i-1] + $c;
241 0         0 my $days = DaysInMonth($mon, $$toTime[$i-2]);
242 0 0       0 last if $v <= $days;
243 0         0 $v -= $days;
244 0         0 ++$c;
245 0 0       0 last if $v <= 28;
246             }
247             } else {
248 0         0 my $fc = ($v - $max - $min) / $max;
249             # carry ($c) must be smallest integer greater than $fc
250 0         0 $c = int($fc);
251 0 0       0 ++$c if $c <= $fc;
252 0         0 $v -= $c * $max;
253             }
254             }
255 354         956 $$toTime[$i] = $v; # save the new value
256             }
257             # handle overflows in timezone
258 60 100       235 if (defined $$toTime[6]) {
259 54         157 my $m = $$toTime[6] * 60 + $$toTime[7];
260 54         168 $m += 0.5 * ($m <=> 0); # avoid round-off errors
261 54         148 $$toTime[6] = int($m / 60);
262 54         160 $$toTime[7] = int($m - $$toTime[6] * 60);
263             }
264 60         281 return undef; # success
265             }
266              
267             #------------------------------------------------------------------------------
268             # Shift an integer or floating-point number
269             # Inputs: 0) date/time string, 1) shift string, 2) shift direction (+1 or -1)
270             # 3) (unused)
271             # Returns: undef and updates input value
272             sub ShiftNumber($$$;$)
273             {
274 6     6 0 27 my ($val, $shift, $dir) = @_;
275 6         30 $_[0] = $val + $shift * $dir; # return shifted value
276 6         68 return undef; # success!
277             }
278              
279             #------------------------------------------------------------------------------
280             # Shift date/time string
281             # Inputs: 0) date/time string, 1) shift string, 2) shift direction (+1 or -1),
282             # or 0 or undef to take shift direction from sign of shift,
283             # 3) reference to ShiftOffset hash (with Date, DateTime, Time, Timezone keys)
284             # or 0) shift string (and operates on $_)
285             # Returns: error string or undef on success and date/time string is updated
286             sub ShiftTime($;$$$)
287             {
288 66     66 0 269 my ($val, $shift, $dir, $shiftOffset);
289 66         0 my (@time, @shift, @toTime, $mode, $needShiftOffset, $dec);
290              
291 66 50       198 if (@_ == 1) { # single argument form of ShiftTime()?
292 0         0 $val = $_;
293 0         0 $shift = $_[0];
294             } else {
295 66         216 ($val, $shift, $dir, $shiftOffset) = @_;
296             }
297 66 0 0     200 $dir or $dir = ($shift =~ s/^(\+|-)// and $1 eq '-') ? -1 : 1;
    50          
298             #
299             # figure out what we are dealing with (time, date or date/time)
300             #
301 66 50       239 SplitTime($val, \@time) or return "Invalid time string ($val)";
302 66 50       255 if (defined $time[0]) {
    0          
303 66 50       218 return "Can't shift from year 0000" if $time[0] eq '0000';
304 66 100       217 $mode = defined $time[3] ? 'DateTime' : 'Date';
305             } elsif (defined $time[3]) {
306 0         0 $mode = 'Time';
307             } else {
308 0         0 $mode = '';
309             }
310             # get number of digits after the seconds decimal point
311 66 100 100     500 if (defined $time[5] and $time[5] =~ /\.(\d+)/) {
312 51         166 $dec = length($1);
313             } else {
314 15         35 $dec = 0;
315             }
316 66 100       182 if ($shiftOffset) {
317 15 100       61 $needShiftOffset = 1 unless defined $$shiftOffset{$mode};
318 15 100 66     71 $needShiftOffset = 1 if defined $time[6] and not defined $$shiftOffset{Timezone};
319             } else {
320 51         122 $needShiftOffset = 1;
321             }
322 66 100       174 if ($needShiftOffset) {
323             #
324             # apply date/time shift the hard way
325             #
326 60 50       199 SplitTime($shift, \@shift, \@time) or return "Invalid shift string ($shift)";
327              
328             # change 'Z' timezone to '+00:00' only if necessary
329 60 50 66     303 if (@shift > 6 and @time <= 6) {
330 0 0       0 $time[6] = $time[7] = 0 if $val =~ s/Z$/\+00:00/;
331             }
332 60         120 my $rndDiff;
333 60         234 my $err = ShiftComponents(\@time, \@shift, $dir, \@toTime, $dec, \$rndDiff);
334 60 50       208 $err and return $err;
335             #
336             # calculate and save the shift offsets for next time
337             #
338 60 100       215 if ($shiftOffset) {
339 9 50 33     44 if (defined $time[0] or defined $time[3]) {
340 9         33 my @tm1 = (0, 0, 0, 1, 0, 2000);
341 9         30 my @tm2 = (0, 0, 0, 1, 0, 2000);
342 9 50       32 if (defined $time[0]) {
343 9         56 @tm1[3..5] = reverse @time[0..2];
344 9         38 @tm2[3..5] = reverse @toTime[0..2];
345 9         22 --$tm1[4]; # month should start from 0
346 9         19 --$tm2[4];
347             }
348 9         21 my $diff = 0;
349 9 100       31 if (defined $time[3]) {
350 7         36 @tm1[0..2] = reverse @time[3..5];
351 7         28 @tm2[0..2] = reverse @toTime[3..5];
352             # handle fractional seconds separately
353 7         22 $diff = $tm2[0] - int($tm2[0]) - ($tm1[0] - int($tm1[0]));
354 7 50       24 $diff += $rndDiff if defined $rndDiff; # un-do rounding
355 7         18 $tm1[0] = int($tm1[0]);
356 7         17 $tm2[0] = int($tm2[0]);
357             }
358 9         1135 eval q{
359             require Time::Local;
360             $diff += Time::Local::timegm(@tm2) - Time::Local::timegm(@tm1);
361             };
362             # not a problem if we failed here since we'll just try again next time,
363             # so don't return error message
364 9 50       1015 unless ($@) {
365 9         19 my $mode;
366 9 50       62 if (defined $time[0]) {
367 9 100       41 $mode = defined $time[3] ? 'DateTime' : 'Date';
368             } else {
369 0         0 $mode = 'Time';
370             }
371 9         52 $$shiftOffset{$mode} = $diff;
372             }
373             }
374 9 100       40 if (defined $time[6]) {
375 3         16 $$shiftOffset{Timezone} = ($toTime[6] - $time[6]) * 60 +
376             $toTime[7] - $time[7];
377             }
378             }
379              
380             } else {
381             #
382             # apply shift from previously calculated offsets
383             #
384 6 50 33     24 if ($$shiftOffset{Timezone} and @time <= 6) {
385             # change 'Z' timezone to '+00:00' only if necessary
386 0 0       0 $time[6] = $time[7] = 0 if $val =~ s/Z$/\+00:00/;
387             }
388             # apply the previous date/time shift if necessary
389 6 50       18 if ($mode) {
390 6         15 my @tm = (0, 0, 0, 1, 0, 2000);
391 6 50       16 if (defined $time[0]) {
392 6         27 @tm[3..5] = reverse @time[0..2];
393 6         17 --$tm[4]; # month should start from 0
394             }
395 6 50       22 @tm[0..2] = reverse @time[3..5] if defined $time[3];
396             # save fractional seconds
397 6         18 my $frac = $tm[0] - int($tm[0]);
398 6         11 $tm[0] = int($tm[0]);
399 6         12 my $tm;
400 6         714 eval q{
401             require Time::Local;
402             $tm = Time::Local::timegm(@tm) + $frac;
403             };
404 6 50       475 $@ and return CleanWarning($@);
405 6         19 $tm += $$shiftOffset{$mode}; # apply the shift
406             # save fractional seconds in shifted time
407 6         13 $frac = $tm - int($tm);
408 6 50       18 if ($frac) {
409 0         0 $tm = int($tm);
410             # must account for any rounding that could occur
411 0 0       0 $frac + 0.5 * 10 ** (-$dec) >= 1 and ++$tm, $frac = 0;
412             }
413 6         49 @tm = gmtime($tm);
414 6         72 @toTime = reverse @tm[0..5];
415 6         17 $toTime[0] += 1900;
416 6         12 ++$toTime[1];
417 6         16 $toTime[5] += $frac; # add the fractional seconds back in
418             }
419             # apply the previous timezone shift if necessary
420 6 50       36 if (defined $time[6]) {
421 0         0 my $m = $time[6] * 60 + $time[7];
422 0         0 $m += $$shiftOffset{Timezone};
423 0         0 $m += 0.5 * ($m <=> 0); # avoid round-off errors
424 0         0 $toTime[6] = int($m / 60);
425 0         0 $toTime[7] = int($m - $toTime[6] * 60);
426             }
427             }
428             #
429             # insert shifted time components back into original string
430             #
431 66         121 my $i;
432 66         239 for ($i=0; $i<@toTime; ++$i) {
433 444 50 33     1821 next unless defined $time[$i] and defined $toTime[$i];
434 444         773 my ($v, $d, $s);
435 444 100       912 if ($i != 6) { # not timezone hours
436 390 50       2037 last unless $val =~ /((?=\d|\.\d)\d*(\.\d*)?)/g;
437 390 100       1377 next if $toTime[$i] == $time[$i];
438 141         440 $v = $1; # value
439 141         313 $d = $2; # decimal part of value
440 141         345 $s = ''; # no sign
441             } else {
442 54 50 33     340 last if $time[$i] == $toTime[$i] and $time[$i+1] == $toTime[$i+1];
443 0 0       0 last unless $val =~ /((?:\+|-)(?=\d|\.\d)\d*(\.\d*)?)/g;
444 0         0 $v = $1;
445 0         0 $d = $2;
446 0 0 0     0 if ($toTime[6] >= 0 and $toTime[7] >= 0) {
447 0         0 $s = '+';
448             } else {
449 0         0 $s = '-';
450 0         0 $toTime[6] = -$toTime[6];
451 0         0 $toTime[7] = -$toTime[7];
452             }
453             }
454 141         271 my $nv = $toTime[$i];
455 141         264 my $pos = pos $val;
456 141         281 my $len = length $v;
457 141         263 my $sig = $len - length $s;
458 141 50       340 my $dec = $d ? length($d) - 1 : 0;
459 141 50       671 my $newNum = sprintf($dec ? "$s%0$sig.${dec}f" : "$s%0${sig}d", $nv);
460 141         452 substr($val, $pos - $len, $len) = $newNum;
461 141         779 pos($val) = $pos + length($newNum) - $len;
462             }
463 66 50       220 if (@_ == 1) {
464 0         0 $_ = $val; # set $_ to the returned value
465             } else {
466 66         175 $_[0] = $val; # return shifted value
467             }
468 66         554 return undef; # success!
469             }
470              
471              
472             1; # end
473              
474             __END__