From 87046ce9e4105c7902e2889706f9bd5eb0affba2 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:50:17 +0530 Subject: [PATCH] Reject aux notes that can't be written in wrann The length of an aux note is stored in one byte and its characters as single bytes, but wrann didn't check either. A note longer than 255 characters wrapped the length and was read back as many extra annotations, and characters outside Latin-1 were silently replaced. Raise a ValueError for these notes before writing, and write the Latin-1 encoded bytes, which is what rdann decodes. --- docs/changes.rst | 3 +++ src/wfdb/io/annotation.py | 24 +++++++++++++++++++++--- tests/test_annotation.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) 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"))