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 }