diff --git a/docs/changes.rst b/docs/changes.rst index ea225812..8c63c074 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -41,6 +41,9 @@ Unreleased **Fix resample_sig for signals starting with NaN** ``resample_sig`` (and the functions that use it, such as ``resample_singlechan`` and ``resample_multichan``) returned only NaN values if the signal started with NaN. Leading NaN values now take the first valid value before resampling, as trailing ones already took the last. +**Reject aux notes that can't be written in wrann** + ``wrann`` now raises a ``ValueError`` if an ``aux_note`` is longer than 255 characters or contains characters outside Latin-1, instead of writing a corrupt annotation file. A longer note used to be read back as many extra annotations, and other characters were silently replaced. + Version 4.3.1 (Feb 2026) ----------------------------- diff --git a/src/wfdb/io/annotation.py b/src/wfdb/io/annotation.py index 20f6b7b1..463cf770 100644 --- a/src/wfdb/io/annotation.py +++ b/src/wfdb/io/annotation.py @@ -544,6 +544,22 @@ def check_field(self, field): + " field must not contain tabs or newlines" ) + if field == "aux_note": + # Each note is stored as Latin-1 bytes after a one-byte length. + for e in uniq_elements: + try: + n_bytes = len(e.encode("latin-1")) + except UnicodeEncodeError: + raise ValueError( + f"aux_note {e!r} contains characters that can't be " + "written (only Latin-1 characters are supported)" + ) from None + if n_bytes > 255: + raise ValueError( + f"aux_note values must be at most 255 characters, " + f"but one has {n_bytes}" + ) + elif field == "sample": if len(self.sample) == 1: sampdiffs = np.array([self.sample[0]]) @@ -1670,9 +1686,10 @@ def field2bytes(field, value, typecodes): # - First byte stores length of aux_note field # - Second byte stores 63*4 indicator # - Then store the aux_note string characters - data_bytes = [len(value), 252] + [ord(i) for i in value] + encoded = value.encode("latin-1") + data_bytes = [len(encoded), 252] + list(encoded) # Zero pad odd length aux_note strings - if len(value) % 2: + if len(encoded) % 2: data_bytes.append(0) return data_bytes @@ -1727,7 +1744,8 @@ def wrann( annotation. aux_note : list, optional A list containing the auxiliary information string (or None for - annotations without notes) for each annotation. + annotations without notes) for each annotation. Each string can have + at most 255 characters, all in the Latin-1 character set. label_store : ndarray, optional A numpy array containing the integer values used to store the annotation labels. If this field is present, `symbol` must not be diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 79c3dec8..2ecfeab3 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd +import pytest import wfdb @@ -287,3 +288,37 @@ def test_5(self, temp_path): ) ann = wfdb.rdann(os.path.join(temp_path, "CustomLabel"), "atr") assert ann.symbol == ["z", "l", "v", "r"] + + +def test_aux_note_round_trip(temp_path): + """Latin-1 aux notes up to 255 characters are read back unchanged.""" + aux_note = ["x" * 255, "37°C, Müller", "(AFIB"] + wfdb.wrann( + "aux", + "atr", + np.array([1, 2, 3]), + symbol=["+", "N", "+"], + aux_note=aux_note, + write_dir=temp_path, + ) + ann = wfdb.rdann(os.path.join(temp_path, "aux"), "atr") + assert ann.sample.tolist() == [1, 2, 3] + assert ann.aux_note == aux_note + + +@pytest.mark.parametrize( + "aux_note, match", + [("x" * 256, "at most 255 characters"), ("(€)", "Latin-1")], +) +def test_aux_note_rejected(temp_path, aux_note, match): + """Aux notes that can't be stored raise an error instead of being corrupted.""" + with pytest.raises(ValueError, match=match): + wfdb.wrann( + "bad_aux", + "atr", + np.array([1, 2]), + symbol=["+", "N"], + aux_note=[aux_note, ""], + write_dir=temp_path, + ) + assert not os.path.exists(os.path.join(temp_path, "bad_aux.atr"))