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