1 /* 2 * #%L 3 * nom.tam FITS library 4 * %% 5 * Copyright (C) 2004 - 2024 nom-tam-fits 6 * %% 7 * This is free and unencumbered software released into the public domain. 8 * 9 * Anyone is free to copy, modify, publish, use, compile, sell, or 10 * distribute this software, either in source code form or as a compiled 11 * binary, for any purpose, commercial or non-commercial, and by any 12 * means. 13 * 14 * In jurisdictions that recognize copyright laws, the author or authors 15 * of this software dedicate any and all copyright interest in the 16 * software to the public domain. We make this dedication for the benefit 17 * of the public at large and to the detriment of our heirs and 18 * successors. We intend this dedication to be an overt act of 19 * relinquishment in perpetuity of all present and future rights to this 20 * software under copyright law. 21 * 22 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 24 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 25 * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 26 * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 27 * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 28 * OTHER DEALINGS IN THE SOFTWARE. 29 * #L% 30 */ 31 32 package nom.tam.fits; 33 34 import nom.tam.fits.FitsFactory.FitsSettings; 35 36 import static nom.tam.fits.header.Standard.CONTINUE; 37 38 /** 39 * Converts {@link HeaderCard}s into one or more 80-character wide FITS header records. It is a replacement for 40 * {@link nom.tam.fits.utilities.FitsLineAppender}, which is still available for external use for backward 41 * compatibility, but is no longer used internally in this library itself. 42 * 43 * @author Attila Kovacs 44 * 45 * @since 1.16 46 */ 47 class HeaderCardFormatter { 48 49 /** 50 * The FITS settings to use, such as support for long strings, support for HIERARCH-style cards, or the use of 'D' 51 * for high-precision exponential values. These settings control when and how header cards are represented exactly 52 * in the FITS header. 53 */ 54 private FitsSettings settings; 55 56 /** The length of two single quotes. */ 57 private static final int QUOTES_LENGTH = 2; 58 59 /** 60 * Character sequence that comes after a value field, and before the comment string in the header record. While only 61 * a '/' character is really required, we like to add spaces around it for a more pleasing visual of the resulting 62 * header record. The space before the '/' is strongly recommended by the FITS standard. 63 */ 64 private static final String COMMENT_PREFIX = " / "; 65 66 /** 67 * Long string comments should not add a space after the '/', because we want to preserve spaces in continued long 68 * string comments, hece we start the comment immediately after the '/' to ensure that internal spaces in wrapped 69 * comments remain intact and properly accounted for. The space before the '/' is strongly recommended by the FITS 70 * standard. 71 */ 72 private static final String LONG_COMMENT_PREFIX = " /"; 73 74 /** 75 * In older FITS standards there was a requirement that a closing quote for string values may not come before byte 76 * 20 (counted from 1) in the header record. To ensure that, strings need to be padded with blank spaces to push the 77 * closing quote out to that position, if necessary. While it is no longer required by the current FITS standard, it 78 * is possible (or even likely) that some existing tools rely on the earlier requirement. Therefore, we will abide 79 * by the requirements of the older standard. (In the future, we may make this requirement optional, and 80 * controllable through the API). 81 */ 82 private static final int MIN_STRING_END = 19; 83 84 /** whatever fits after "CONTINUE '' /" */ 85 private static final int MAX_LONG_END_COMMENT = 68 - LONG_COMMENT_PREFIX.length(); 86 87 /** 88 * Instantiates a new header card formatter with the specified FITS settings. 89 * 90 * @param settings the local FITS settings to use by this card formatter. 91 * 92 * @see #HeaderCardFormatter() 93 */ 94 HeaderCardFormatter(FitsSettings settings) { 95 this.settings = settings; 96 } 97 98 /** 99 * Converts a {@link HeaderCard} to one or more 80-character wide FITS header records following the FITS rules, and 100 * the various conventions that are allowed by the FITS settings with which this card formatter instance was 101 * created. 102 * 103 * @param card the header card object 104 * 105 * @return the correspoinding FITS header snipplet, as one or more 80-character wide 106 * header 'records'. 107 * 108 * @throws HierarchNotEnabledException if the cards is a HIERARCH-style card, but support for HIERARCH keywords 109 * is not enabled in the FITS settings used by this formatter. 110 * @throws LongValueException if the (non-string) value stored in the card cannot fit in the header 111 * record. 112 * @throws LongStringsNotEnabledException if the card contains a string value that cannot fit into a single header 113 * record, and the use of long string is not enabled in the FITS settings 114 * used by this formatter. 115 * 116 * @see FitsFactory#setLongStringsEnabled(boolean) 117 */ 118 String toString(HeaderCard card) 119 throws HierarchNotEnabledException, LongValueException, LongStringsNotEnabledException { 120 StringBuffer buf = new StringBuffer(HeaderCard.FITS_HEADER_CARD_SIZE); 121 122 appendKey(buf, card); 123 124 int valueStart = appendValue(buf, card); 125 int valueEnd = buf.length(); 126 127 appendComment(buf, card); 128 129 if (!card.isCommentStyleCard()) { 130 // Strings must be left aligned with opening quote in byte 11 (counted from 1) 131 realign(buf, card.isStringValue() ? valueEnd : valueStart, valueEnd); 132 } 133 134 pad(buf); 135 136 return HeaderCard.sanitize(new String(buf)); 137 } 138 139 /** 140 * Adds the FITS keyword to the header record (normally at the beginning). 141 * 142 * @param buf The string buffer in which we are building the header record. 143 * @param card The header card to be formatted. 144 * 145 * @throws HierarchNotEnabledException if the card contains a HIERARCH-style long keyword, but support for these has 146 * not been enabled in the settings used by this formatter. 147 * @throws LongValueException if the HIERARCH keyword is itself too long to fit on the record without 148 * leaving a minimum amount of space for a value. 149 * 150 * @see FitsFactory#setUseHierarch(boolean) 151 */ 152 private void appendKey(StringBuffer buf, HeaderCard card) throws HierarchNotEnabledException, LongValueException { 153 String key = card.getKey(); 154 155 if (card.hasHierarchKey()) { 156 if (!settings.isUseHierarch()) { 157 throw new HierarchNotEnabledException(key); 158 } 159 key = settings.getHierarchKeyFormatter().toHeaderString(key); 160 if (key.length() > HeaderCard.MAX_HIERARCH_KEYWORD_LENGTH) { 161 // Truncate HIERARCH keywords as necessary to fit. 162 // This is really just a second parachute here. Normally, HeaderCards 163 // won't allow creation or setting longer keywords... 164 throw new LongValueException(key, HeaderCard.MAX_HIERARCH_KEYWORD_LENGTH); 165 } 166 } else { 167 // Just to be certain, we'll make sure base keywords are upper-case, if they 168 // were not already. 169 key = key.toUpperCase(); 170 } 171 172 buf.append(key); 173 174 padTo(buf, HeaderCard.MAX_KEYWORD_LENGTH); 175 } 176 177 /** 178 * Adds the FITS value to the header record (normally after the keyword), including the standard "= " assigment 179 * marker in front of it, or the non-standard "=" (without space after) if 180 * {@link FitsFactory#setSkipBlankAfterAssign(boolean)} is set <code>true</code>. 181 * 182 * @param buf The string buffer in which we are building the header record. 183 * @param card The header card to be formatted. 184 * 185 * @return the buffer position at which the appended value starts, or the last 186 * posirtion if a value was not added at all. (This is used for 187 * realigning later...) 188 * 189 * @throws LongValueException if the card contained a non-string value that is too long to fit in the 190 * space available in the current record. 191 * @throws LongStringsNotEnabledException if the card contains a string value that cannot fit into a single header 192 * record, and the use of long string is not enabled in the FITS settings 193 * used by this formatter. 194 */ 195 private int appendValue(StringBuffer buf, HeaderCard card) throws LongValueException, LongStringsNotEnabledException { 196 String value = card.getValue(); 197 198 if (card.isCommentStyleCard()) { 199 // omment-style card. Nothing to do here... 200 return buf.length(); 201 } 202 203 // Add assignment sequence "= " 204 buf.append(getAssignString()); 205 206 if (value == null) { 207 // 'null' value, nothing more to append. 208 return buf.length(); 209 } 210 211 int valueStart = buf.length(); 212 213 if (card.isStringValue()) { 214 int from = appendQuotedValue(buf, card, 0); 215 while (from < value.length()) { 216 pad(buf); 217 buf.append(CONTINUE.key() + " "); 218 from += appendQuotedValue(buf, card, from); 219 } 220 // TODO We prevent the creation of cards with longer values, so the following check is dead code here. 221 // } else if (value.length() > available) { 222 // throw new LongValueException(available, card.getKey(), card.getValue()); 223 } else { 224 append(buf, value, 0); 225 } 226 227 return valueStart; 228 } 229 230 /** 231 * Returns the minimum size of a truncated header comment. When truncating header comments we should preserve at 232 * least the first word of the comment string wholly... 233 * 234 * @param card The header card to be formatted. 235 * 236 * @return the length of the first word in the comment string 237 */ 238 private int getMinTruncatedCommentSize(HeaderCard card) { 239 String comment = card.getComment(); 240 241 // TODO We check for null before calling, so this is dead code here... 242 // if (comment == null) { 243 // return 0; 244 // } 245 246 int firstWordLength = comment.indexOf(' '); 247 if (firstWordLength < 0) { 248 firstWordLength = comment.length(); 249 } 250 251 return COMMENT_PREFIX.length() + firstWordLength; 252 } 253 254 /** 255 * Appends the comment to the header record, or as much of it as possible, but never less than the first word (at 256 * minimum). 257 * 258 * @param buf The string buffer in which we are building the header record. 259 * @param card The header card to be formatted. 260 * 261 * @return <code>true</code> if the comment was fully represented in the record, or <code>false</code> if it 262 * was truncated or fully ommitted. 263 */ 264 private boolean appendComment(StringBuffer buf, HeaderCard card) { 265 String comment = card.getComment(); 266 if ((comment == null) || comment.isEmpty()) { 267 return true; 268 } 269 270 int available = getAvailable(buf); 271 boolean longCommentOK = FitsFactory.isLongStringsEnabled() && card.isStringValue(); 272 273 if (!card.isCommentStyleCard() && longCommentOK) { 274 if (COMMENT_PREFIX.length() + card.getComment().length() > available) { 275 // No room for a complete regular comment, but we can do a long string comment... 276 appendLongStringComment(buf, card); 277 return true; 278 } 279 } 280 281 if (card.isCommentStyleCard()) { 282 // ' ' instead of '= ' 283 available--; 284 } else { 285 // ' / ' 286 available -= COMMENT_PREFIX.length(); 287 if (getMinTruncatedCommentSize(card) > available) { 288 if (!longCommentOK) { 289 return false; 290 } 291 } 292 } 293 294 if (card.isCommentStyleCard()) { 295 buf.append(' '); 296 } else { 297 buf.append(COMMENT_PREFIX); 298 } 299 300 if (available >= comment.length()) { 301 buf.append(comment); 302 return true; 303 } 304 305 buf.append(comment.substring(0, available)); 306 return false; 307 } 308 309 /** 310 * Realigns the header record (single records only!) for more pleasing visual appearance by adding padding after a 311 * string value, or before a non-string value, as necessary to push the comment field to the alignment position, if 312 * it's possible without truncating the existing record. 313 * 314 * @param buf The string buffer in which we are building the header record. 315 * @param at The position at which to insert padding 316 * @param from The position in the record that is to be pushed to the alignment position. 317 * 318 * @return <code>true</code> if the card was successfully realigned. Otherwise <code>false</code>. 319 */ 320 private boolean realign(StringBuffer buf, int at, int from) { 321 if ((buf.length() >= HeaderCard.FITS_HEADER_CARD_SIZE) || (from >= Header.getCommentAlignPosition())) { 322 // We are beyond the alignment point already... 323 return false; 324 } 325 326 return realign(buf, at, from, Header.getCommentAlignPosition()); 327 } 328 329 /** 330 * Realigns the header record (single records only!) for more pleasing visual appearance by adding padding after a 331 * string value, or before a non-string value, as necessary to push the comment field to the specified alignment 332 * position, if it's possible without truncating the existing record 333 * 334 * @param buf The string buffer in which we are building the header record. 335 * @param at The position at which to insert padding 336 * @param from The position in the record that is to be pushed to the alignment position. 337 * @param to The new alignment position. 338 * 339 * @return <code>true</code> if the card was successfully realigned. Otherwise <code>false</code>. 340 */ 341 private boolean realign(StringBuffer buf, int at, int from, int to) { 342 int spaces = to - from; 343 344 if (spaces > getAvailable(buf)) { 345 // No space left in card to align the the specified position. 346 return false; 347 } 348 349 StringBuffer sBuf = new StringBuffer(spaces); 350 while (--spaces >= 0) { 351 sBuf.append(' '); 352 } 353 354 buf.insert(at, sBuf.toString()); 355 356 return true; 357 } 358 359 /** 360 * Adds a long string comment. When long strings are enabled, it is possible to fully preserve a comment of any 361 * length after a string value, by wrapping into multiple records with CONTINUE keywords. Crucially, we will want to 362 * do this in a way as to preserve internal spaces within the comment, when wrapped into multiple records. 363 * 364 * @param buf The string buffer in which we are building the header record. 365 * @param card The header card to be formatted. 366 */ 367 private void appendLongStringComment(StringBuffer buf, HeaderCard card) { 368 // We can wrap the comment to our delight, with CONTINUE! 369 int iLast = buf.length() - 1; 370 String comment = card.getComment(); 371 372 // We need to amend the last string to end with '&' 373 if (getAvailable(buf) >= LONG_COMMENT_PREFIX.length() + comment.length()) { 374 // We can append the entire comment, easy... 375 buf.append(LONG_COMMENT_PREFIX); 376 append(buf, comment, 0); 377 return; 378 } 379 380 // Add '&' to the end of the string value. 381 // appendQuotedValue() must always leave space for it! 382 buf.setCharAt(iLast, '&'); 383 buf.append("'"); 384 385 int from = 0; 386 387 int available = getAvailable(buf); 388 389 // If there is room for a standard inline comment, then go for it 390 if (available < COMMENT_PREFIX.length()) { 391 // Add a CONTINUE card with an empty string and try again... 392 pad(buf); 393 buf.append(CONTINUE.key() + " ''"); 394 appendComment(buf, card); 395 return; 396 } 397 buf.append(COMMENT_PREFIX); 398 399 from = append(buf, comment, 0); 400 401 // Now add records as needed to write the comment fully... 402 while (from < comment.length()) { 403 pad(buf); 404 buf.append(CONTINUE.key() + " "); 405 buf.append((comment.length() >= from + MAX_LONG_END_COMMENT) ? "'&'" : "''"); 406 buf.append(LONG_COMMENT_PREFIX); 407 from += append(buf, comment, from); 408 } 409 } 410 411 /** 412 * Appends as many characters as possible from a string, starting at the specified string position, into the header 413 * record. 414 * 415 * @param buf The string buffer in which we are building the header record. 416 * @param text The string from which to append characters up to the end of the record. 417 * @param from The starting position in the string 418 * 419 * @return the number of characters deposited into the header record from the string after the starting 420 * position. 421 */ 422 private int append(StringBuffer buf, String text, int from) { 423 int available = getAvailable(buf); 424 425 int n = Math.min(available, text.length() - from); 426 if (n < 1) { 427 return 0; 428 } 429 430 for (int i = 0; i < n; i++) { 431 buf.append(text.charAt(from + i)); 432 } 433 434 return n; 435 } 436 437 /** 438 * Appends quoted text from the specified string position, until the end of the string is reached, or until the 439 * 80-character header record is full. It replaces quotes in the string with doubled quotes, while making sure that 440 * not unclosed quotes are left and there is space for an '&' character for 441 * 442 * @param buf The string buffer in which we are building the header record. 443 * @param card The header card whose value to quote in the header record. 444 * @param from The starting position in the string. 445 * 446 * @return the number of characters consumed from the string, which may be different from the number of 447 * characters deposited as each single quote in the input string is represented as 2 single quotes 448 * in the record. 449 */ 450 private int appendQuotedValue(StringBuffer buf, HeaderCard card, int from) { 451 // Always leave room for an extra & character at the end... 452 int available = getAvailable(buf) - QUOTES_LENGTH; 453 454 // If long strings are enabled leave space for '&' at the end. 455 if (FitsFactory.isLongStringsEnabled() && card.getComment() != null) { 456 if (card.getComment().length() > 0) { 457 available--; 458 } 459 } 460 461 String text = card.getValue(); 462 463 // TODO We check for null before calling, so this is dead code here... 464 // if (text == null) { 465 // return 0; 466 // } 467 468 // The the remaining part of the string fits in the space with the 469 // quoted quotes, then it's easy... 470 if (available >= text.length() - from) { 471 String escaped = text.substring(from).replace("'", "''"); 472 473 if (escaped.length() <= available) { 474 buf.append('\''); 475 buf.append(escaped); 476 477 // Earlier versions of the FITS standard required that the closing quote 478 // does not come before byte 20. It's no longer required but older tools 479 // may still expect it, so let's conform. This only affects single 480 // record card, but not continued long strings... 481 if (buf.length() < MIN_STRING_END) { 482 padTo(buf, MIN_STRING_END); 483 } 484 485 buf.append('\''); 486 return text.length() - from; 487 } 488 } 489 490 if (!FitsFactory.isLongStringsEnabled()) { 491 throw new LongStringsNotEnabledException(card.getKey() + "= " + card.getValue()); 492 } 493 494 // Now, we definitely need space for '&' at the end... 495 available = getAvailable(buf) - QUOTES_LENGTH - 1; 496 497 // We need room for an '&' character at the end also... 498 // TODO Again we prevent this ever occuring before we reach this point, so it is dead code... 499 // if (available < 1) { 500 // return 0; 501 // } 502 503 // Opening quote 504 buf.append("'"); 505 506 // For counting the characters consumed from the input 507 int consumed = 0; 508 509 for (int i = 0; i < available; i++, consumed++) { 510 // TODO We already know we cannot show the whole string on one line, so this is dead code... 511 // if (from + i >= text.length()) { 512 // // Reached end of string; 513 // break; 514 // } 515 516 char c = text.charAt(from + consumed); 517 518 if (c == '\'') { 519 // Quoted quotes take up 2 spaces... 520 i++; 521 if (i + 1 >= available) { 522 // Otherwise leave the value quote unconsumed. 523 break; 524 } 525 // Only append the quoted quote if there is room for both. 526 buf.append("''"); 527 } else { 528 // Append a non-quote character. 529 buf.append(c); 530 } 531 } 532 533 // & and Closing quote 534 buf.append("&'"); 535 536 return consumed; 537 } 538 539 /** 540 * Adds a specific amount of padding (empty spaces) in the header record. 541 * 542 * @param buf The string buffer in which we are building the header record. 543 * @param n the number of empty spaces to add. 544 */ 545 private void pad(StringBuffer buf, int n) { 546 for (int i = n; --i >= 0;) { 547 buf.append(' '); 548 } 549 } 550 551 /** 552 * Pads the current header record with empty spaces to up to the end of the 80-character record. 553 * 554 * @param buf The string buffer in which we are building the header record. 555 */ 556 private void pad(StringBuffer buf) { 557 pad(buf, getAvailable(buf)); 558 } 559 560 /** 561 * Adds padding (empty spaces) in the header record, up to the specified position within the record. 562 * 563 * @param buf The string buffer in which we are building the header record. 564 * @param to The position in the record to which to pad with spaces. 565 */ 566 private void padTo(StringBuffer buf, int to) { 567 for (int pos = buf.length() % HeaderCard.FITS_HEADER_CARD_SIZE; pos < to; pos++) { 568 buf.append(' '); 569 } 570 } 571 572 /** 573 * Returns the number of characters available for remaining fields in the current record. Empty records will return 574 * 0. 575 * 576 * @param buf The string buffer in which we are building the header record. 577 * 578 * @return the number of characters still available in the currently started 80-character header record. Empty 579 * records will return 0. 580 */ 581 private int getAvailable(StringBuffer buf) { 582 return (HeaderCard.FITS_HEADER_CARD_SIZE - buf.length() % HeaderCard.FITS_HEADER_CARD_SIZE) 583 % HeaderCard.FITS_HEADER_CARD_SIZE; 584 } 585 586 /** 587 * Returns the assignment string to use between the keyword and the value. The FITS standard requires the 588 * 2-character sequence "= ", but for some reason we allow to skip the required space after the '=' if 589 * {@link FitsFactory#setSkipBlankAfterAssign(boolean)} is set to <code>true</code>... 590 * 591 * @return The character sequence to insert between the keyword and the value. 592 * 593 * @see #getAssignLength() 594 */ 595 @SuppressWarnings("deprecation") 596 static String getAssignString() { 597 return FitsFactory.isSkipBlankAfterAssign() ? "=" : "= "; 598 } 599 600 /** 601 * Returns the number of characters we use for assignment. Normally, it should be 2 as per FITS standard, but if 602 * {@link FitsFactory#setSkipBlankAfterAssign(boolean)} is set to <code>true</code>, it may be only 1. 603 * 604 * @return The number of characters that should be between the keyword and the value indicating assignment. 605 * 606 * @see #getAssignString() 607 */ 608 @SuppressWarnings("deprecation") 609 static int getAssignLength() { 610 int n = 1; 611 if (!FitsFactory.isSkipBlankAfterAssign()) { 612 n++; 613 } 614 return n; 615 } 616 }