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