View Javadoc
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 }