From 55e29916ccaa89fae740b6704a38954ebd5f68b9 Mon Sep 17 00:00:00 2001 From: CAOShurong <170531907+CAOShurong@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:03:46 +0800 Subject: [PATCH] Read Igor Pro 8 binary wave version 7 names A wave whose name is longer than 31 bytes is written as version 7. igor2 rejected that version, so packed.load dropped the record. The name follows the version 5 sections and replaces the placeholder in bname. Fixes #32 --- CHANGELOG | 2 ++ igor2/binarywave.py | 44 ++++++++++++++++++++++++++ tests/helper.py | 33 ++++++++++++++++++++ tests/test_ibw.py | 76 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_pxp.py | 18 ++++++++++- 5 files changed, 171 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4aefb42..c7b7443 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +0.5.16 + - fix: read Igor Pro 8 binary wave version 7 long wave names (#32) 0.5.15 - fix: read Igor Pro 8 long data-folder names (#28, #30) - tests: add trimmed Igor Pro 8 experiment from #28 diff --git a/igor2/binarywave.py b/igor2/binarywave.py index 373cba1..247bc37 100644 --- a/igor2/binarywave.py +++ b/igor2/binarywave.py @@ -563,6 +563,35 @@ def post_unpack(self, parents, data): wave_data[self.name] = dim_labels +class DynamicLongWaveNameField (DynamicStringField): + """Igor Pro 8 name for a wave that does not fit in 31 bytes. + + Version 7 keeps the version 5 layout and stores the name after the + last version 5 section. ``optionsSize1``, which TN003 says to + ignore, is the byte count. The bytes are not NUL-terminated. Igor + writes ``:wave name too long:`` in ``bname`` when this name is + present. + """ + + _size_field = 'optionsSize1' + + def pre_unpack(self, parents, data): + size = self._get_size_data(parents, data) + if size < 0: + raise ValueError( + 'long wave name length {} is negative'.format(size)) + super(DynamicLongWaveNameField, self).pre_unpack(parents, data) + + def post_unpack(self, parents, data): + super(DynamicLongWaveNameField, self).post_unpack(parents, data) + wave_structure = parents[-1] + wave_data = self._get_structure_data(parents, data, wave_structure) + name = wave_data[self.name] + placeholder = b':wave name too long:' + if name and wave_data['wave_header']['bname'] == placeholder: + wave_data['wave_header']['bname'] = name + + class DynamicStringIndicesDataField (_DynamicField): """String indices used for text waves only """ @@ -633,6 +662,7 @@ def post_unpack(self, parents, data): 2: Wave2, 3: Wave3, 5: Wave5, + 7: Wave7, }) old_format = wave_structure.fields[-1].format if version in version_map: @@ -797,11 +827,24 @@ def post_unpack(self, parents, data): ]) +# Version 7 is version 5 plus the long name. Built from a clone so the +# version 5 field objects stay independent. +Wave7 = _clone_structure(Wave5) +Wave7.name = 'Wave7' +Wave7.fields.append(DynamicLongWaveNameField( + 'c', 'long_name', + help='Long wave name stored after the version 5 sections.', + count=0, + array=True)) +Wave7.setup() + + def setup_wave(byte_order='='): wave1 = _clone_structure(Wave1) wave2 = _clone_structure(Wave2) wave3 = _clone_structure(Wave3) wave5 = _clone_structure(Wave5) + wave7 = _clone_structure(Wave7) wave = _DynamicStructure( name='Wave', fields=[ @@ -820,6 +863,7 @@ def setup_wave(byte_order='='): 2: wave2, 3: wave3, 5: wave5, + 7: wave7, } wave.setup() return wave diff --git a/tests/helper.py b/tests/helper.py index 4b08d5b..69df15f 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -1,10 +1,43 @@ import pathlib +import struct + import numpy as _numpy from pprint import PrettyPrinter from igor2.binarywave import load as loadibw data_dir = pathlib.Path(__file__).parent / "data" +# Igor writes this in the 32-byte bname field when the real name follows +# the version 5 sections. See issue 32. +LONG_WAVE_NAME_PLACEHOLDER = b':wave name too long:' + + +def version7_from_version5(source, name, order, replace_bname=True): + """Return version 5 bytes relabeled as a version 7 long-name wave. + + ``name`` is appended after the version 5 sections and recorded in + ``optionsSize1``. The numeric payload is left unchanged. + """ + data = bytearray(source) + if order == '<': + if data[:2] != b'\x05\x00': + raise ValueError(data[:2]) + data[:2] = b'\x07\x00' + data[56:60] = struct.pack('': + if data[:2] != b'\x00\x05': + raise ValueError(data[:2]) + data[:2] = b'\x00\x07' + data[56:60] = struct.pack('>i', len(name)) + else: + raise ValueError(order) + if replace_bname: + field = LONG_WAVE_NAME_PLACEHOLDER + data[92:124] = field + b'\x00' * (32 - len(field)) + if name: + data.extend(name) + return bytes(data) + class NumpyPrettyPrinter(PrettyPrinter): def format(self, object, context, maxlevels, level): diff --git a/tests/test_ibw.py b/tests/test_ibw.py index bbdadf2..9c36428 100644 --- a/tests/test_ibw.py +++ b/tests/test_ibw.py @@ -1,5 +1,9 @@ +from io import BytesIO + +import numpy as np + from helper import assert_equal_dump_no_whitespace_no_byte, dumpibw -from helper import data_dir, loadibw +from helper import data_dir, loadibw, version7_from_version5 def test_ibw01(): @@ -641,3 +645,73 @@ def test_multidimensional_text_wave(): [b'dog', b'wolf'], [b'fish', b'shark'], ] + + +def test_version7_long_wave_name(): + # 32-byte name from issue 32, plus a longer name that cannot fit + # in the version 5 bname field even without a trailing NUL. + issue_name = b'r_ReactedQ_1p73Inlet_0006_270_30' + longer_name = issue_name + b'_extra' + assert len(issue_name) == 32 + cases = ( + ('<', 'win-version5.ibw'), + ('>', 'mac-version5.ibw'), + ) + for order, filename in cases: + original = (data_dir / filename).read_bytes() + original_wave = loadibw(BytesIO(original))['wave'] + for name in (issue_name, longer_name): + loaded = loadibw(BytesIO( + version7_from_version5(original, name, order))) + assert loaded['version'] == 7 + wave = loaded['wave'] + assert wave['wave_header']['bname'] == name + assert wave['long_name'] == name + assert wave['bin_header']['optionsSize1'] == len(name) + assert wave['note'] == original_wave['note'] + assert np.array_equal(wave['wData'], original_wave['wData']) + + +def test_version7_without_long_name_keeps_short_bname(): + original = (data_dir / 'win-version5.ibw').read_bytes() + renamed = bytearray(original) + renamed[:2] = b'\x07\x00' + loaded = loadibw(BytesIO(bytes(renamed))) + original_wave = loadibw(BytesIO(original))['wave'] + assert loaded['version'] == 7 + assert loaded['wave']['wave_header']['bname'] == b'version5' + assert loaded['wave']['long_name'] == b'' + assert np.array_equal( + loaded['wave']['wData'], original_wave['wData']) + + +def test_version7_keeps_bname_when_it_is_not_the_placeholder(): + original = (data_dir / 'win-version5.ibw').read_bytes() + extra = b'not-the-wave-name' + loaded = loadibw(BytesIO(version7_from_version5( + original, extra, '<', replace_bname=False))) + assert loaded['wave']['wave_header']['bname'] == b'version5' + assert loaded['wave']['long_name'] == extra + + +def test_version7_rejects_negative_name_length(): + original = bytearray((data_dir / 'win-version5.ibw').read_bytes()) + original[:2] = b'\x07\x00' + original[56:60] = b'\xff\xff\xff\xff' + try: + loadibw(BytesIO(bytes(original))) + except ValueError as exc: + assert 'negative' in str(exc) + else: + raise AssertionError('negative long wave name length was accepted') + + +def test_version7_rejects_truncated_name(): + original = (data_dir / 'win-version5.ibw').read_bytes() + blob = version7_from_version5(original, b'abcdef', '<')[:-3] + try: + loadibw(BytesIO(blob)) + except ValueError as exc: + assert 'not enough data' in str(exc) + else: + raise AssertionError('truncated long wave name was accepted') diff --git a/tests/test_pxp.py b/tests/test_pxp.py index 6674634..cc2b23f 100644 --- a/tests/test_pxp.py +++ b/tests/test_pxp.py @@ -8,7 +8,7 @@ from igor2.binarywave import load as loadibw from igor2.packed import load as loadpxp -from helper import data_dir +from helper import data_dir, version7_from_version5 def tostr(data): @@ -320,6 +320,22 @@ def test_igor8_long_folder_name(): assert children[short_name] == {} +def test_version7_long_wave_name_in_packed_experiment(): + name = b'r_ReactedQ_1p73Inlet_0006_270_30_more' + cases = ( + ('<', 'win-version5.ibw'), + ('>', 'mac-version5.ibw'), + ) + for order, filename in cases: + payload = version7_from_version5( + (data_dir / filename).read_bytes(), name, order) + records, filesystem = loadpxp(BytesIO( + _pxp_record(3, 1, payload, order))) + assert set(filesystem['root']) == {name} + assert records[0].wave['wave']['wave_header']['bname'] == name + assert records[0].wave['wave']['wData'].shape == (5,) + + def test_long_folder_name_rejects_bad_length(): payload = struct.pack('