Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions bindings/matlab/binsparse_to_ssmc_problem.m
Original file line number Diff line number Diff line change
Expand Up @@ -442,16 +442,26 @@ 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)
value = value(:);
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<AGROW>
end
value = char(rows);
end

function value = normalize_component_text(value, use_cellstr)
Expand Down
42 changes: 33 additions & 9 deletions bindings/matlab/binsparse_write_ssmc_problem.m
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -253,21 +256,42 @@ 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
ok = true;
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);
Expand Down
32 changes: 32 additions & 0 deletions bindings/matlab/test_binsparse_to_ssmc_problem.m
Original file line number Diff line number Diff line change
Expand Up @@ -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( ...
Expand Down
37 changes: 36 additions & 1 deletion bindings/matlab/test_binsparse_write_ssmc_problem.m
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -84,15 +85,49 @@ 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');
assert(~isempty(regexp(json, '"id"\s*:\s*7', 'once')), ...
'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<NASGU>
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
Expand Down
Loading