Skip to content

Commit 74a3f56

Browse files
authored
Reject aux notes that can't be written in wrann (#605)
`wrann` writes each aux note as a one-byte length followed by one byte per character, but it doesn't check that the note fits. The bytes are cast to `uint8`, so values above 255 wrap silently: ```python wfdb.wrann("a", "atr", sample=np.array([1, 2]), symbol=["+", "N"], aux_note=["x" * 256, ""]) len(wfdb.rdann("a", "atr").sample) # 130 annotations instead of 2 wfdb.wrann("a", "atr", sample=np.array([1]), symbol=["+"], aux_note=["(€)"]) wfdb.rdann("a", "atr").aux_note # ['(¬)'] ``` `rdann` decodes each byte with `chr`, i.e. as Latin-1, so the notes that round-trip are those of at most 255 Latin-1 characters (the length byte also limits notes to 255 bytes in the WFDB format). Changes: - `check_field` now raises a `ValueError` for an aux note that is longer than 255 characters or has characters outside Latin-1, before anything is written. - `field2bytes` writes `value.encode("latin-1")` and takes the length from the encoded bytes. For valid notes the output is the same as before. - The `wrann` docstring mentions the limits, and there's a changelog entry. Tests: a round trip with a 255-character note and Latin-1 characters (`°`, `ü`), and both kinds of invalid note raising the error without creating a file. The invalid-note tests fail on `main`. `pytest -m "not network"` passes (99 tests), and the docs build passes.
2 parents b0b5b5f + 87046ce commit 74a3f56

3 files changed

Lines changed: 59 additions & 3 deletions

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Unreleased
4141
**Fix resample_sig for signals starting with NaN**
4242
``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.
4343

44+
**Reject aux notes that can't be written in wrann**
45+
``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.
46+
4447
Version 4.3.1 (Feb 2026)
4548
-----------------------------
4649

‎src/wfdb/io/annotation.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,22 @@ def check_field(self, field):
544544
+ " field must not contain tabs or newlines"
545545
)
546546

547+
if field == "aux_note":
548+
# Each note is stored as Latin-1 bytes after a one-byte length.
549+
for e in uniq_elements:
550+
try:
551+
n_bytes = len(e.encode("latin-1"))
552+
except UnicodeEncodeError:
553+
raise ValueError(
554+
f"aux_note {e!r} contains characters that can't be "
555+
"written (only Latin-1 characters are supported)"
556+
) from None
557+
if n_bytes > 255:
558+
raise ValueError(
559+
f"aux_note values must be at most 255 characters, "
560+
f"but one has {n_bytes}"
561+
)
562+
547563
elif field == "sample":
548564
if len(self.sample) == 1:
549565
sampdiffs = np.array([self.sample[0]])
@@ -1670,9 +1686,10 @@ def field2bytes(field, value, typecodes):
16701686
# - First byte stores length of aux_note field
16711687
# - Second byte stores 63*4 indicator
16721688
# - Then store the aux_note string characters
1673-
data_bytes = [len(value), 252] + [ord(i) for i in value]
1689+
encoded = value.encode("latin-1")
1690+
data_bytes = [len(encoded), 252] + list(encoded)
16741691
# Zero pad odd length aux_note strings
1675-
if len(value) % 2:
1692+
if len(encoded) % 2:
16761693
data_bytes.append(0)
16771694

16781695
return data_bytes
@@ -1727,7 +1744,8 @@ def wrann(
17271744
annotation.
17281745
aux_note : list, optional
17291746
A list containing the auxiliary information string (or None for
1730-
annotations without notes) for each annotation.
1747+
annotations without notes) for each annotation. Each string can have
1748+
at most 255 characters, all in the Latin-1 character set.
17311749
label_store : ndarray, optional
17321750
A numpy array containing the integer values used to store the
17331751
annotation labels. If this field is present, `symbol` must not be

‎tests/test_annotation.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import numpy as np
55
import pandas as pd
6+
import pytest
67

78
import wfdb
89

@@ -287,3 +288,37 @@ def test_5(self, temp_path):
287288
)
288289
ann = wfdb.rdann(os.path.join(temp_path, "CustomLabel"), "atr")
289290
assert ann.symbol == ["z", "l", "v", "r"]
291+
292+
293+
def test_aux_note_round_trip(temp_path):
294+
"""Latin-1 aux notes up to 255 characters are read back unchanged."""
295+
aux_note = ["x" * 255, "37°C, Müller", "(AFIB"]
296+
wfdb.wrann(
297+
"aux",
298+
"atr",
299+
np.array([1, 2, 3]),
300+
symbol=["+", "N", "+"],
301+
aux_note=aux_note,
302+
write_dir=temp_path,
303+
)
304+
ann = wfdb.rdann(os.path.join(temp_path, "aux"), "atr")
305+
assert ann.sample.tolist() == [1, 2, 3]
306+
assert ann.aux_note == aux_note
307+
308+
309+
@pytest.mark.parametrize(
310+
"aux_note, match",
311+
[("x" * 256, "at most 255 characters"), ("(€)", "Latin-1")],
312+
)
313+
def test_aux_note_rejected(temp_path, aux_note, match):
314+
"""Aux notes that can't be stored raise an error instead of being corrupted."""
315+
with pytest.raises(ValueError, match=match):
316+
wfdb.wrann(
317+
"bad_aux",
318+
"atr",
319+
np.array([1, 2]),
320+
symbol=["+", "N"],
321+
aux_note=[aux_note, ""],
322+
write_dir=temp_path,
323+
)
324+
assert not os.path.exists(os.path.join(temp_path, "bad_aux.atr"))

0 commit comments

Comments
 (0)