1 // ================================================================================================= 2 // ADOBE SYSTEMS INCORPORATED 3 // Copyright 2006 Adobe Systems Incorporated 4 // All Rights Reserved 5 // 6 // NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms 7 // of the Adobe license agreement accompanying it. 8 // ================================================================================================= 9 10 package com.adobe.xmp.impl; 11 12 import java.text.DecimalFormat; 13 import java.text.DecimalFormatSymbols; 14 import java.util.Locale; 15 import java.util.SimpleTimeZone; 16 17 import com.adobe.xmp.XMPDateTime; 18 import com.adobe.xmp.XMPError; 19 import com.adobe.xmp.XMPException; 20 21 22 /** 23 * Converts between ISO 8601 Strings and <code>Calendar</code> with millisecond resolution. 24 * 25 * @since 16.02.2006 26 */ 27 public final class ISO8601Converter 28 { 29 /** Hides public constructor */ 30 private ISO8601Converter() 31 { 32 // EMPTY 33 } 34 35 36 /** 37 * Converts an ISO 8601 string to an <code>XMPDateTime</code>. 38 * 39 * Parse a date according to ISO 8601 and 40 * http://www.w3.org/TR/NOTE-datetime: 41 * <ul> 42 * <li>YYYY 43 * <li>YYYY-MM 44 * <li>YYYY-MM-DD 45 * <li>YYYY-MM-DDThh:mmTZD 46 * <li>YYYY-MM-DDThh:mm:ssTZD 47 * <li>YYYY-MM-DDThh:mm:ss.sTZD 48 * </ul> 49 * 50 * Data fields: 51 * <ul> 52 * <li>YYYY = four-digit year 53 * <li>MM = two-digit month (01=January, etc.) 54 * <li>DD = two-digit day of month (01 through 31) 55 * <li>hh = two digits of hour (00 through 23) 56 * <li>mm = two digits of minute (00 through 59) 57 * <li>ss = two digits of second (00 through 59) 58 * <li>s = one or more digits representing a decimal fraction of a second 59 * <li>TZD = time zone designator (Z or +hh:mm or -hh:mm) 60 * </ul> 61 * 62 * Note that ISO 8601 does not seem to allow years less than 1000 or greater 63 * than 9999. We allow any year, even negative ones. The year is formatted 64 * as "%.4d". 65 * <p> 66 * <em>Note:</em> Tolerate missing TZD, assume is UTC. Photoshop 8 writes 67 * dates like this for exif:GPSTimeStamp.<br> 68 * <em>Note:</em> Tolerate missing date portion, in case someone foolishly 69 * writes a time-only value that way. 70 * 71 * @param iso8601String a date string that is ISO 8601 conform. 72 * @return Returns a <code>Calendar</code>. 73 * @throws XMPException Is thrown when the string is non-conform. 74 */ 75 public static XMPDateTime parse(String iso8601String) throws XMPException 76 { 77 return parse(iso8601String, new XMPDateTimeImpl()); 78 } 79 80 81 /** 82 * @param iso8601String a date string that is ISO 8601 conform. 83 * @param binValue an existing XMPDateTime to set with the parsed date 84 * @return Returns an XMPDateTime-object containing the ISO8601-date. 85 * @throws XMPException Is thrown when the string is non-conform. 86 */ 87 public static XMPDateTime parse(String iso8601String, XMPDateTime binValue) throws XMPException 88 { 89 ParameterAsserts.assertNotNull(iso8601String); 90 91 ParseState input = new ParseState(iso8601String); 92 int value; 93 94 boolean timeOnly = 95 input.ch(0) == 'T' || 96 (input.length() >= 2 && input.ch(1) == ':' || 97 (input.length() >= 3 && input.ch(2) == ':')); 98 99 if (!timeOnly) 100 { 101 if (input.ch(0) == '-') 102 { 103 input.skip(); 104 } 105 106 107 // Extract the year. 108 value = input.gatherInt("Invalid year in date string", 9999); 109 if (input.hasNext() && input.ch() != '-') 110 { 111 throw new XMPException("Invalid date string, after year", XMPError.BADVALUE); 112 } 113 114 if (input.ch(0) == '-') 115 { 116 value = -value; 117 } 118 binValue.setYear(value); 119 if (!input.hasNext()) 120 { 121 return binValue; 122 } 123 input.skip(); 124 125 126 // Extract the month. 127 value = input.gatherInt("Invalid month in date string", 12); 128 if (input.hasNext() && input.ch() != '-') 129 { 130 throw new XMPException("Invalid date string, after month", XMPError.BADVALUE); 131 } 132 binValue.setMonth(value); 133 if (!input.hasNext()) 134 { 135 return binValue; 136 } 137 input.skip(); 138 139 140 // Extract the day. 141 value = input.gatherInt("Invalid day in date string", 31); 142 if (input.hasNext() && input.ch() != 'T') 143 { 144 throw new XMPException("Invalid date string, after day", XMPError.BADVALUE); 145 } 146 binValue.setDay(value); 147 if (!input.hasNext()) 148 { 149 return binValue; 150 } 151 } 152 else 153 { 154 // set default day and month in the year 0000 155 binValue.setMonth(1); 156 binValue.setDay(1); 157 } 158 159 if (input.ch() == 'T') 160 { 161 input.skip(); 162 } 163 else if (!timeOnly) 164 { 165 throw new XMPException("Invalid date string, missing 'T' after date", 166 XMPError.BADVALUE); 167 } 168 169 170 // Extract the hour. 171 value = input.gatherInt("Invalid hour in date string", 23); 172 if (input.ch() != ':') 173 { 174 throw new XMPException("Invalid date string, after hour", XMPError.BADVALUE); 175 } 176 binValue.setHour(value); 177 178 // Don't check for done, we have to work up to the time zone. 179 input.skip(); 180 181 182 // Extract the minute. 183 value = input.gatherInt("Invalid minute in date string", 59); 184 if (input.hasNext() && 185 input.ch() != ':' && input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-') 186 { 187 throw new XMPException("Invalid date string, after minute", XMPError.BADVALUE); 188 } 189 binValue.setMinute(value); 190 191 if (input.ch() == ':') 192 { 193 input.skip(); 194 value = input.gatherInt("Invalid whole seconds in date string", 59); 195 if (input.hasNext() && input.ch() != '.' && input.ch() != 'Z' && 196 input.ch() != '+' && input.ch() != '-') 197 { 198 throw new XMPException("Invalid date string, after whole seconds", 199 XMPError.BADVALUE); 200 } 201 binValue.setSecond(value); 202 if (input.ch() == '.') 203 { 204 input.skip(); 205 int digits = input.pos(); 206 value = input.gatherInt("Invalid fractional seconds in date string", 999999999); 207 if (input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-') 208 { 209 throw new XMPException("Invalid date string, after fractional second", 210 XMPError.BADVALUE); 211 } 212 digits = input.pos() - digits; 213 for (; digits > 9; --digits) 214 { 215 value = value / 10; 216 } 217 for (; digits < 9; ++digits) 218 { 219 value = value * 10; 220 } 221 binValue.setNanoSecond(value); 222 } 223 } 224 225 int tzSign = 0; 226 int tzHour = 0; 227 int tzMinute = 0; 228 if (input.ch() == 'Z') 229 { 230 input.skip(); 231 } 232 else if (input.hasNext()) 233 { 234 if (input.ch() == '+') 235 { 236 tzSign = 1; 237 } 238 else if (input.ch() == '-') 239 { 240 tzSign = -1; 241 } 242 else 243 { 244 throw new XMPException("Time zone must begin with 'Z', '+', or '-'", 245 XMPError.BADVALUE); 246 } 247 248 input.skip(); 249 // Extract the time zone hour. 250 tzHour = input.gatherInt("Invalid time zone hour in date string", 23); 251 if (input.ch() != ':') 252 { 253 throw new XMPException("Invalid date string, after time zone hour", 254 XMPError.BADVALUE); 255 } 256 input.skip(); 257 258 // Extract the time zone minute. 259 tzMinute = input.gatherInt("Invalid time zone minute in date string", 59); 260 } 261 262 // create a corresponding TZ and set it time zone 263 int offset = (tzHour * 3600 * 1000 + tzMinute * 60 * 1000) * tzSign; 264 binValue.setTimeZone(new SimpleTimeZone(offset, "")); 265 266 267 if (input.hasNext()) 268 { 269 throw new XMPException( 270 "Invalid date string, extra chars at end", XMPError.BADVALUE); 271 } 272 273 return binValue; 274 } 275 276 277 /** 278 * Converts a <code>Calendar</code> into an ISO 8601 string. 279 * Format a date according to ISO 8601 and http://www.w3.org/TR/NOTE-datetime: 280 * <ul> 281 * <li>YYYY 282 * <li>YYYY-MM 283 * <li>YYYY-MM-DD 284 * <li>YYYY-MM-DDThh:mmTZD 285 * <li>YYYY-MM-DDThh:mm:ssTZD 286 * <li>YYYY-MM-DDThh:mm:ss.sTZD 287 * </ul> 288 * 289 * Data fields: 290 * <ul> 291 * <li>YYYY = four-digit year 292 * <li>MM = two-digit month (01=January, etc.) 293 * <li>DD = two-digit day of month (01 through 31) 294 * <li>hh = two digits of hour (00 through 23) 295 * <li>mm = two digits of minute (00 through 59) 296 * <li>ss = two digits of second (00 through 59) 297 * <li>s = one or more digits representing a decimal fraction of a second 298 * <li>TZD = time zone designator (Z or +hh:mm or -hh:mm) 299 * </ul> 300 * <p> 301 * <em>Note:</em> ISO 8601 does not seem to allow years less than 1000 or greater than 9999. 302 * We allow any year, even negative ones. The year is formatted as "%.4d".<p> 303 * <em>Note:</em> Fix for bug 1269463 (silently fix out of range values) included in parsing. 304 * The quasi-bogus "time only" values from Photoshop CS are not supported. 305 * 306 * @param dateTime an XMPDateTime-object. 307 * @return Returns an ISO 8601 string. 308 */ 309 public static String render(XMPDateTime dateTime) 310 { 311 StringBuffer buffer = new StringBuffer(); 312 313 // year is rendered in any case, even 0000 314 DecimalFormat df = new DecimalFormat("0000", new DecimalFormatSymbols(Locale.ENGLISH)); 315 buffer.append(df.format(dateTime.getYear())); 316 if (dateTime.getMonth() == 0) 317 { 318 return buffer.toString(); 319 } 320 321 // month 322 df.applyPattern("'-'00"); 323 buffer.append(df.format(dateTime.getMonth())); 324 if (dateTime.getDay() == 0) 325 { 326 return buffer.toString(); 327 } 328 329 // day 330 buffer.append(df.format(dateTime.getDay())); 331 332 // time, rendered if any time field is not zero 333 if (dateTime.getHour() != 0 || 334 dateTime.getMinute() != 0 || 335 dateTime.getSecond() != 0 || 336 dateTime.getNanoSecond() != 0 || 337 (dateTime.getTimeZone() != null && dateTime.getTimeZone().getRawOffset() != 0)) 338 { 339 // hours and minutes 340 buffer.append('T'); 341 df.applyPattern("00"); 342 buffer.append(df.format(dateTime.getHour())); 343 buffer.append(':'); 344 buffer.append(df.format(dateTime.getMinute())); 345 346 // seconds and nanoseconds 347 if (dateTime.getSecond() != 0 || dateTime.getNanoSecond() != 0) 348 { 349 double seconds = dateTime.getSecond() + dateTime.getNanoSecond() / 1e9d; 350 351 df.applyPattern(":00.#########"); 352 buffer.append(df.format(seconds)); 353 } 354 355 // time zone 356 if (dateTime.getTimeZone() != null) 357 { 358 // used to calculate the time zone offset incl. Daylight Savings 359 long timeInMillis = dateTime.getCalendar().getTimeInMillis(); 360 int offset = dateTime.getTimeZone().getOffset(timeInMillis); 361 if (offset == 0) 362 { 363 // UTC 364 buffer.append('Z'); 365 } 366 else 367 { 368 int thours = offset / 3600000; 369 int tminutes = Math.abs(offset % 3600000 / 60000); 370 df.applyPattern("+00;-00"); 371 buffer.append(df.format(thours)); 372 df.applyPattern(":00"); 373 buffer.append(df.format(tminutes)); 374 } 375 } 376 } 377 return buffer.toString(); 378 } 379 380 381 } 382 383 384 /** 385 * @since 22.08.2006 386 */ 387 class ParseState 388 { 389 /** */ 390 private String str; 391 /** */ 392 private int pos = 0; 393 394 395 /** 396 * @param str initializes the parser container 397 */ 398 public ParseState(String str) 399 { 400 this.str = str; 401 } 402 403 404 /** 405 * @return Returns the length of the input. 406 */ 407 public int length() 408 { 409 return str.length(); 410 } 411 412 413 /** 414 * @return Returns whether there are more chars to come. 415 */ 416 public boolean hasNext() 417 { 418 return pos < str.length(); 419 } 420 421 422 /** 423 * @param index index of char 424 * @return Returns char at a certain index. 425 */ 426 public char ch(int index) 427 { 428 return index < str.length() ? 429 str.charAt(index) : 430 0x0000; 431 } 432 433 434 /** 435 * @return Returns the current char or 0x0000 if there are no more chars. 436 */ 437 public char ch() 438 { 439 return pos < str.length() ? 440 str.charAt(pos) : 441 0x0000; 442 } 443 444 445 /** 446 * Skips the next char. 447 */ 448 public void skip() 449 { 450 pos++; 451 } 452 453 454 /** 455 * @return Returns the current position. 456 */ 457 public int pos() 458 { 459 return pos; 460 } 461 462 463 /** 464 * Parses a integer from the source and sets the pointer after it. 465 * @param errorMsg Error message to put in the exception if no number can be found 466 * @param maxValue the max value of the number to return 467 * @return Returns the parsed integer. 468 * @throws XMPException Thrown if no integer can be found. 469 */ 470 public int gatherInt(String errorMsg, int maxValue) throws XMPException 471 { 472 int value = 0; 473 boolean success = false; 474 char ch = ch(pos); 475 while ('0' <= ch && ch <= '9') 476 { 477 value = (value * 10) + (ch - '0'); 478 success = true; 479 pos++; 480 ch = ch(pos); 481 } 482 483 if (success) 484 { 485 if (value > maxValue) 486 { 487 return maxValue; 488 } 489 else if (value < 0) 490 { 491 return 0; 492 } 493 else 494 { 495 return value; 496 } 497 } 498 else 499 { 500 throw new XMPException(errorMsg, XMPError.BADVALUE); 501 } 502 } 503 } 504 505 506