diff --git a/bindings/matlab/binsparse_to_ssmc_problem.m b/bindings/matlab/binsparse_to_ssmc_problem.m index 3309216..dbc4746 100644 --- a/bindings/matlab/binsparse_to_ssmc_problem.m +++ b/bindings/matlab/binsparse_to_ssmc_problem.m @@ -442,8 +442,13 @@ function require_strictly_increasing(values, label) end function value = text_rows(value) +% Rebuild a Matlab char matrix from metadata text. The text is normally a +% single multi-line string, but older files store an array of rows, so any +% embedded line terminators are split out of every element. Rows are never +% deblanked here: whatever padding the writer kept is what char() needs to +% restore the original width. if ischar(value) - return; + value = num2cell(value, 2); elseif isstring(value) value = cellstr(value(:)); elseif iscellstr(value) @@ -451,7 +456,12 @@ function require_strictly_increasing(values, label) else error('BinSparse:InvalidMetadata', 'notes must contain text'); end -value = char(value); +rows = cell(0, 1); +for k = 1:numel(value) + pieces = regexp(value{k}, '\r\n|\n|\r', 'split'); + rows = [rows; pieces(:)]; %#ok +end +value = char(rows); end function value = normalize_component_text(value, use_cellstr) diff --git a/bindings/matlab/binsparse_write_ssmc_problem.m b/bindings/matlab/binsparse_write_ssmc_problem.m index bb1b11e..2fba51c 100644 --- a/bindings/matlab/binsparse_write_ssmc_problem.m +++ b/bindings/matlab/binsparse_write_ssmc_problem.m @@ -185,20 +185,23 @@ function write_string_dataset(output_filename, name, value) 'BSP text output requires binsparse_write_string_dataset on the path'); end + % Trailing blanks are stripped row by row, as sstextwrite does for the + % MM and RB formats. A single row is left alone: it is the widest row, + % so its blanks are what a char() rebuild needs to recover the width. if isstring(value) if isscalar(value) value = char(value); else - value = cellstr(value(:)); + value = strip_text_rows(value); end elseif ischar(value) if size(value, 1) > 1 - value = char_rows(value); + value = strip_text_rows(value); else value = char(value); end elseif iscellstr(value) - value = value(:); + value = strip_text_rows(value); else error('binsparse_write_ssmc_problem:InvalidStringValue', ... 'Text aux value must be char, string, or cellstr'); @@ -244,7 +247,7 @@ function write_string_dataset(output_filename, name, value) ok = false; if ischar(value) if size(value, 1) > 1 - value = char_rows(value); + value = text_block(value); else value = char(value); end @@ -253,7 +256,7 @@ function write_string_dataset(output_filename, name, value) if isscalar(value) value = char(value); else - value = cellstr(value(:)); + value = text_block(value); end ok = true; elseif (islogical(value) || isnumeric(value)) && numel(value) <= 64 @@ -261,13 +264,34 @@ function write_string_dataset(output_filename, name, value) end end -function rows = char_rows(value) - rows = cell(size(value, 1), 1); - for k = 1:size(value, 1) - rows{k} = value(k, :); +function rows = strip_text_rows(value) + % Strip the trailing blanks that Matlab uses to pad the rows of a char + % matrix. A reader rebuilds the matrix with char(), which pads every + % row to the longest one, so the original width survives only if some + % row reaches it; when none does, the widest row keeps just enough + % padding to preserve it. The round trip is then exact, with trailing + % blanks left on at most one row. Cell and string arrays carry no such + % padding, so their elements are simply stripped. + if ischar(value) + raw = num2cell(value, 2); + width = size(value, 2); + else + raw = cellstr(value(:)); + width = 0; + end + rows = regexprep(raw, ' +$', ''); + lengths = cellfun('length', rows); + if ~isempty(lengths) && max(lengths) < width + [~, k] = max(lengths); + rows{k} = [rows{k} repmat(' ', 1, width - lengths(k))]; end end +function text = text_block(value) + % Join multi-row text into a single multi-line string. + text = strjoin(reshape(strip_text_rows(value), 1, []), sprintf('\n')); +end + function json = encode_json_or_empty(value) try json = jsonencode(value); diff --git a/bindings/matlab/test_binsparse_to_ssmc_problem.m b/bindings/matlab/test_binsparse_to_ssmc_problem.m index 6f57293..3f0b252 100644 --- a/bindings/matlab/test_binsparse_to_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_to_ssmc_problem.m @@ -74,11 +74,43 @@ assert(isequal(Problem.aux.seq{2}, [7; 8])); assert(isequal(Problem.aux.label, char({'abc'; 'def'}))); +% A stripped string dataset rebuilds the char matrix it was written from, +% including the width carried by the widest row. +raw.aux.label = {'hello '; 'there'}; +Problem = binsparse_to_ssmc_problem(raw); +assert(isequal(Problem.aux.label, ['hello '; 'there ']), ... + 'stripped string dataset mismatch'); + dmat = dense_matrix([1; 2; 3; 4; 5; 6], 2, 3, 'DMAT'); raw = struct('metadata', metadata, 'A', formats{1}, 'b', dmat); Problem = binsparse_to_ssmc_problem(raw); assert(isequal(Problem.b, [1 2 3; 4 5 6])); +% notes may be a single multi-line string, and blank rows are preserved. +notes_meta = metadata; +notes_meta.notes = sprintf('a longest line with trailing space \nshort\n\nlast'); +Problem = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(Problem.notes, char({'a longest line with trailing space '; ... + 'short'; ''; 'last'})), 'multi-line notes mismatch'); +assert(isequal(size(Problem.notes), [4 35]), 'multi-line notes width mismatch'); + +notes_meta.notes = sprintf('abc\n'); +Problem = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(Problem.notes, char({'abc'; ''})), 'trailing blank note row lost'); + +% Padded row arrays written by older versions read back the same way. +notes_meta.notes = {'first note '; 'second note'}; +legacy = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +notes_meta.notes = sprintf('first note\nsecond note'); +joined = binsparse_to_ssmc_problem( ... + struct('metadata', notes_meta, 'A', formats{1})); +assert(isequal(legacy.notes, joined.notes), ... + 'legacy and multi-line notes disagree'); +assert(isequal(joined.notes, char({'first note'; 'second note'}))); + bad = formats{1}; bad.indices_1([1 2]) = bad.indices_1([2 1]); assert_throws(@() binsparse_to_ssmc_problem( ... diff --git a/bindings/matlab/test_binsparse_write_ssmc_problem.m b/bindings/matlab/test_binsparse_write_ssmc_problem.m index 7b6824d..f6ec3d2 100644 --- a/bindings/matlab/test_binsparse_write_ssmc_problem.m +++ b/bindings/matlab/test_binsparse_write_ssmc_problem.m @@ -53,6 +53,7 @@ function test_binsparse_write_ssmc_problem() Problem.aux.D = [1 0 2; 3 4 5]; Problem.aux.S = sparse([1 2], [2 3], [9 8], 3, 3); Problem.aux.note = ['hello '; 'there ']; +Problem.aux.note2 = ['hello!'; 'there ']; Problem.aux.tags = {'alpha'; 'beta'}; problem = struct('Problem', Problem); @@ -84,8 +85,16 @@ function test_binsparse_write_ssmc_problem() expected_sparse = full(Problem.aux.S); assert(matrices_equal(aux_sparse_mat, expected_sparse), 'Aux sparse matrix mismatch'); -check_string_dataset(out_file, 'note', {'hello '; 'there '}); +% String datasets are stripped row by row. No row of note reaches the char +% matrix width, so the widest row keeps one blank; note2 needs none. Either +% way char() rebuilds the original char matrix exactly. +check_string_dataset(out_file, 'note', {'hello '; 'there'}); +check_string_dataset(out_file, 'note2', {'hello!'; 'there'}); check_string_dataset(out_file, 'tags', {'alpha'; 'beta'}); +assert(isequal(char(as_cellstr(h5read(out_file, '/note'))), Problem.aux.note), ... + 'Aux note round trip is not exact'); +assert(isequal(char(as_cellstr(h5read(out_file, '/note2'))), Problem.aux.note2), ... + 'Aux note2 round trip is not exact'); json = h5readatt(out_file, '/', 'binsparse'); assert(contains(json, '"metadata"'), 'Primary metadata not nested'); @@ -93,6 +102,32 @@ function test_binsparse_write_ssmc_problem() 'Primary metadata id is not a JSON number'); assert(~contains(json, '"ssmc_metadata"'), 'Unexpected legacy metadata key'); +% notes are stored as one multi-line string. Trailing blanks are stripped, +% except on the widest row when that row is what carries the char matrix +% width, so that char() rebuilds the original notes exactly. +decoded = jsondecode(json); +notes_text = decoded.metadata.notes; +assert(ischar(notes_text) && isrow(notes_text), ... + 'Notes metadata is not a single string'); +assert(isequal(notes_text, sprintf('first note\nsecond note ')), ... + 'Notes metadata text mismatch'); +rebuilt = char(regexp(notes_text, '\r\n|\n|\r', 'split')); +assert(isequal(rebuilt, Problem.notes), 'Notes round trip is not exact'); + +% When the widest row has no padding, no trailing blanks survive at all. +clean = Problem; +clean.notes = ['abc'; 'de ']; +clean_file = [tempname() '.bsp.h5']; +cleanup_clean = onCleanup(@() delete_if_exists(clean_file)); %#ok +binsparse_write_ssmc_problem(struct('Problem', clean), clean_file, ... + format, compression_level); +clean_notes = jsondecode(h5readatt(clean_file, '/', 'binsparse')); +clean_notes = clean_notes.metadata.notes; +assert(isequal(clean_notes, sprintf('abc\nde')), ... + 'Notes metadata retained recoverable padding'); +assert(isequal(char(regexp(clean_notes, '\n', 'split')), clean.notes), ... + 'Notes round trip is not exact'); + fprintf('Test passed.\n'); end