From bcbbc96aa722c2401355530cf0a1b43690020ff4 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 18 Dec 2025 15:43:48 +0000 Subject: [PATCH 1/3] fix(fits): preserve doubled quotes in long strings --- astropy/io/fits/card.py | 31 ++++++++++++-- astropy/io/fits/tests/test_header.py | 63 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/astropy/io/fits/card.py b/astropy/io/fits/card.py index 1b3285ddce92..fd8f0de60bde 100644 --- a/astropy/io/fits/card.py +++ b/astropy/io/fits/card.py @@ -859,9 +859,29 @@ def _split(self): return kw, vc value = m.group("strg") or "" - value = value.rstrip().replace("''", "'") + trailing = vc[m.end("strg") : m.end(0)] + if trailing: + trailing_spaces = trailing.replace("'", "") + if trailing_spaces: + space_len = len(trailing_spaces) - len(trailing_spaces.lstrip(" ")) + if space_len > 0: + value += trailing_spaces[:space_len] + remainder = vc[m.end(0) :] if value and value[-1] == "&": value = value[:-1] + if remainder: + remainder = remainder.rstrip() + if remainder and not remainder.startswith("/"): + # Handle value fragments that were not consumed by the + # regex match (e.g. text following doubled quotes + # before the continuation marker) by appending them to + # the accumulated value. + extra_value = remainder + if "/" in extra_value: + extra_value = extra_value.split("/", 1)[0] + if "&" in extra_value: + extra_value = extra_value.split("&", 1)[0] + value += extra_value values.append(value) comment = m.group("comm") if comment: @@ -870,8 +890,13 @@ def _split(self): if keyword in self._commentary_keywords: valuecomment = "".join(values) else: - # CONTINUE card - valuecomment = f"'{''.join(values)}' / {' '.join(comments)}" + joined = "".join(values) + actual_value = joined.replace("''", "'") + encoded_value = _format_value(actual_value).strip() + if comments: + valuecomment = f"{encoded_value} / {' '.join(comments)}" + else: + valuecomment = encoded_value return keyword, valuecomment if self.keyword in self._special_keywords: diff --git a/astropy/io/fits/tests/test_header.py b/astropy/io/fits/tests/test_header.py index c573100c91d2..48e777f4c86a 100644 --- a/astropy/io/fits/tests/test_header.py +++ b/astropy/io/fits/tests/test_header.py @@ -468,6 +468,69 @@ def test_long_string_value_with_multiple_long_words(self): "CONTINUE 'xml' " ) + @pytest.mark.parametrize("comment", [None, "comment"]) + def test_long_string_trailing_doubled_quotes_round_trip(self, comment): + for n in range(60, 100): + value = "x" * n + "''" + if comment is None: + card = fits.Card("CONFIG", value) + image = card.image + else: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", VerifyWarning) + card = fits.Card("CONFIG", value, comment) + image = card.image + emitted_warning = any( + isinstance(w.message, VerifyWarning) for w in caught + ) + if len(image) == card.length: + assert emitted_warning + else: + assert not emitted_warning + round_trip = fits.Card.fromstring(image) + assert round_trip.value == value + if comment is None: + assert round_trip.comment == "" + else: + if len(card.image) > card.length: + assert round_trip.comment == comment + else: + parts = card.image.split("/", 1) + expected_comment = parts[1].strip() if len(parts) == 2 else "" + assert round_trip.comment == expected_comment + + def test_long_string_embedded_doubled_quotes_across_boundaries(self): + prefix = "A" * 48 + suffix = "B" * 64 + value = prefix + "''" + suffix + card = fits.Card("BOUND", value, "spans boundaries") + round_trip = fits.Card.fromstring(card.image) + assert round_trip.value == value + assert round_trip.comment == "spans boundaries" + + @pytest.mark.parametrize( + "value, comment", + [ + ("x" * 100 + "''", "comment"), + ("x" * 100 + "'' aaa", "comment"), + ], + ) + def test_long_string_regression_cases_with_trailing_quotes(self, value, comment): + card = fits.Card("FOO", value, comment) + round_trip = fits.Card.fromstring(card.image) + assert round_trip.value == value + assert round_trip.comment == comment + + def test_null_string_round_trip_including_continued_value(self): + card = fits.Card("NULL", "''") + round_trip = fits.Card.fromstring(card.image) + assert round_trip.value == "''" + + long_value = "prefix-" + "y" * 48 + "''" + "z" * 48 + "-suffix" + long_card = fits.Card("NULL", long_value) + long_round_trip = fits.Card.fromstring(long_card.image) + assert long_round_trip.value == long_value + def test_long_unicode_string(self): """Regression test for https://github.com/spacetelescope/PyFITS/issues/1 From 6e69ff730edf75673e9d189460e1e79c695e33da Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 18 Dec 2025 16:00:58 +0000 Subject: [PATCH 2/3] fix(fits): tighten continue trailing spaces --- astropy/io/fits/card.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/astropy/io/fits/card.py b/astropy/io/fits/card.py index fd8f0de60bde..db145e567700 100644 --- a/astropy/io/fits/card.py +++ b/astropy/io/fits/card.py @@ -858,32 +858,29 @@ def _split(self): if not m: return kw, vc + comment = m.group("comm") value = m.group("strg") or "" - trailing = vc[m.end("strg") : m.end(0)] - if trailing: - trailing_spaces = trailing.replace("'", "") - if trailing_spaces: - space_len = len(trailing_spaces) - len(trailing_spaces.lstrip(" ")) - if space_len > 0: - value += trailing_spaces[:space_len] - remainder = vc[m.end(0) :] - if value and value[-1] == "&": + if value.endswith("&"): value = value[:-1] - if remainder: - remainder = remainder.rstrip() - if remainder and not remainder.startswith("/"): - # Handle value fragments that were not consumed by the - # regex match (e.g. text following doubled quotes - # before the continuation marker) by appending them to - # the accumulated value. - extra_value = remainder - if "/" in extra_value: - extra_value = extra_value.split("/", 1)[0] - if "&" in extra_value: - extra_value = extra_value.split("&", 1)[0] + + if not comment: + trailing = vc[m.end("strg") : m.end(0)] + if trailing: + trailing_spaces = trailing.replace("'", "") + if trailing_spaces: + value += trailing_spaces + + remainder = vc[m.end(0) :] + if remainder and not comment: + extra_value = remainder.rstrip() + if extra_value.endswith("'"): + extra_value = extra_value[:-1] + extra_value = extra_value.rstrip() + if extra_value.endswith("&"): + extra_value = extra_value[:-1] + if extra_value: value += extra_value values.append(value) - comment = m.group("comm") if comment: comments.append(comment.rstrip()) From 7124d7984cab36f2bb2218878dc44490fe4cacf9 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 18 Dec 2025 16:20:10 +0000 Subject: [PATCH 3/3] fix(fits): guard comment whitespace recovery --- astropy/io/fits/card.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astropy/io/fits/card.py b/astropy/io/fits/card.py index db145e567700..2280c438604b 100644 --- a/astropy/io/fits/card.py +++ b/astropy/io/fits/card.py @@ -863,7 +863,7 @@ def _split(self): if value.endswith("&"): value = value[:-1] - if not comment: + if comment is None: trailing = vc[m.end("strg") : m.end(0)] if trailing: trailing_spaces = trailing.replace("'", "") @@ -871,7 +871,7 @@ def _split(self): value += trailing_spaces remainder = vc[m.end(0) :] - if remainder and not comment: + if remainder and comment is None: extra_value = remainder.rstrip() if extra_value.endswith("'"): extra_value = extra_value[:-1]