R/ifcb_mat_io.R

Defines functions read_mat_v5 .parse_matrix_body .inflate_incremental .decode_char .decode_numeric .mat_data_type_to_class .read_element write_mat_v5 .mat_widen_numeric mat_var_char mat_var_cell mat_var_uint16 mat_var_double mat_var_numeric .mat_c_time .mat_header .mat_compress_element .mat_cell_array .mat_numeric_matrix .mat_numeric_codec .mat_char_matrix .mat_name .mat_dims .mat_array_flags .mat_element .u32_from_raw .raw_32vec_from_double .raw_i32vec .raw_u16vec .raw_dbl .raw_u32 .raw_u16

# Internal pure-R reader and writer for MATLAB Level 5 MAT-files.
#
# This re-implements the subset of `scipy.io` behaviour required by iRfcb, so
# that `.mat` files can be created and edited without calling Python. The
# writer output is byte-for-byte identical to `scipy.io.savemat` (modulo the
# 128-byte text header, which embeds a creation timestamp that necessarily
# differs between runs), including the optional per-variable zlib compression.
# The reader returns variable specifications that round-trip exactly through
# the writer, reproducing the byte layout of the equivalent
# `loadmat -> savemat` Python round-trip used by the "read - modify - write"
# functions (ifcb_correct_annotation, ifcb_replace_mat_values,
# ifcb_adjust_classes).
#
# Supported element types (see the `mat_var_*` constructors):
#   - numeric matrices of any MATLAB class (double, single, int/uint of any
#     width), including empty 0x0 arrays and NaN
#   - cell arrays of character strings (any dimensions)
#   - character arrays: a single string (1xN), or a multi-row char matrix
#     (e.g. filelistTB in ifcb-analysis summary files), held as one string
#     per row
#
# The serialisation follows the MAT-file v5 format documented in the MATLAB
# "MAT-File Format" reference, matching the exact choices scipy makes:
#   - character data is stored as miUTF8
#   - subelements whose data is <= 4 bytes use the "small data element" format
#   - each top-level variable is compressed independently with zlib

# ---- MAT-file datatype codes ----
.MI_INT8   <- 1L
.MI_UINT8  <- 2L
.MI_INT16  <- 3L
.MI_UINT16 <- 4L
.MI_INT32  <- 5L
.MI_UINT32 <- 6L
.MI_SINGLE <- 7L
.MI_DOUBLE <- 9L
.MI_MATRIX <- 14L
.MI_COMPRESSED <- 15L
.MI_UTF8   <- 16L

# ---- MAT-file array class codes ----
# Only the classes the reader and writer act on get a constant. The codes it
# merely refuses are named in .MX_CLASS_NAMES below, where the label alongside
# each key already says which class it is.
.MX_CELL   <- 1L
.MX_CHAR   <- 4L
.MX_DOUBLE <- 6L
.MX_SINGLE <- 7L
.MX_UINT16 <- 11L
.MX_INT32  <- 12L
.MX_UINT32 <- 13L

# Array classes the reader can decode. Anything else (struct, object, sparse,
# function handle) must be rejected rather than fall through to the numeric
# branch, which would read an unrelated subelement as data and return a
# plausible-looking number. That matters because callers such as
# ifcb_adjust_classes() write the parsed variables straight back over the
# original file.
.MX_SUPPORTED <- c(
  .MX_CELL, .MX_CHAR, .MX_DOUBLE,
  7L,  # mxSINGLE
  8L,  # mxINT8
  9L,  # mxUINT8
  10L, # mxINT16
  .MX_UINT16,
  12L, # mxINT32
  13L  # mxUINT32
)

# Human-readable names for the classes we refuse, used in the error message.
# A list rather than a character vector, so that `[[` on an unlisted class code
# (undocumented or corrupt) yields NULL instead of a "subscript out of bounds"
# error and the message below can name the code on its own. Single-bracket
# indexing would not do: it returns a named NA, which is not NULL and renders
# as "(NA)".
.MX_CLASS_NAMES <- list(
  "2" = "struct", "3" = "object", "5" = "sparse",
  "14" = "int64", "15" = "uint64", "16" = "function handle",
  # mxOPAQUE, how MATLAB stores string arrays, tables, categoricals and class
  # objects. Not in the documented class list, but common in files saved by
  # recent MATLAB releases.
  "17" = "opaque (string, table, categorical or object)"
)

# Value ranges of the integer array classes, used by the writer to refuse a
# value that cannot be stored in the class it is being written as. Without the
# check the encoders wrap silently: 300 written into a uint8 classlist (the
# storage MATLAB picks when every value is small) comes back as 44.
.MX_INT_RANGES <- list(
  "8"  = list(label = "int8",   min = -128,        max = 127),
  "9"  = list(label = "uint8",  min = 0,           max = 255),
  "10" = list(label = "int16",  min = -32768,      max = 32767),
  "11" = list(label = "uint16", min = 0,           max = 65535),
  "12" = list(label = "int32",  min = -2147483648, max = 2147483647),
  "13" = list(label = "uint32", min = 0,           max = 4294967295)
)

# Array-flags bits (second byte of the flags word).
.MX_FLAG_LOGICAL <- 0x02L
.MX_FLAG_COMPLEX <- 0x08L

# =============================================================================
# Writer
# =============================================================================

# ---- Low-level raw encoders ----
.raw_u16 <- function(x) writeBin(as.integer(x), raw(), size = 2L, endian = "little")
.raw_u32 <- function(x) writeBin(as.integer(x), raw(), size = 4L, endian = "little")
.raw_dbl <- function(x) writeBin(as.double(x), raw(), size = 8L, endian = "little")

# Encode an unsigned 16-bit integer vector little-endian (handles 0-65535)
.raw_u16vec <- function(x) {
  x <- as.integer(round(x))
  lo <- bitwAnd(x, 255L)
  hi <- bitwAnd(bitwShiftR(x, 8L), 255L)
  as.raw(as.vector(rbind(lo, hi)))
}

# Encode a signed 32-bit integer vector little-endian
.raw_i32vec <- function(x) {
  writeBin(as.integer(x), raw(), size = 4L, endian = "little")
}

# Encode a 32-bit integer vector little-endian from doubles, covering the whole
# int32 and uint32 ranges. R's integer type cannot express either endpoint -
# `as.integer(-2147483648)` and `as.integer(2147483648)` are both NA, since R
# reserves -2^31 for NA itself - so going through as.integer() silently turns
# the extreme value of each type into a missing one. Splitting the double into
# bytes avoids the conversion entirely.
.raw_32vec_from_double <- function(x) {
  x <- as.double(x) %% 4294967296  # fold into [0, 2^32), so int32 wraps to two's complement
  bytes <- rbind(
    x %% 256,
    (x %/% 256) %% 256,
    (x %/% 65536) %% 256,
    (x %/% 16777216) %% 256
  )
  as.raw(as.vector(bytes))
}

# Decode a little-endian 32-bit integer vector as doubles in [0, 2^32). The
# counterpart of .raw_32vec_from_double(): readBin() cannot return either
# endpoint as an R integer, so the bytes are combined directly.
.u32_from_raw <- function(data) {
  # Drop any trailing partial word, as readBin(n = length(data) %/% 4L) did;
  # matrix() would otherwise recycle it into a fabricated value.
  n <- length(data) %/% 4L
  if (n == 0L) return(numeric(0))
  b <- matrix(as.integer(data[seq_len(n * 4L)]), nrow = 4L)
  b[1, ] + b[2, ] * 256 + b[3, ] * 65536 + b[4, ] * 16777216
}

# Write one data element (tag + data). Uses the "small data element" format
# when the data is 4 bytes or fewer, exactly as scipy does. For 0-byte data
# the small and regular formats are byte-identical.
.mat_element <- function(datatype, data_raw) {
  n <- length(data_raw)
  if (n <= 4L) {
    c(.raw_u16(datatype), .raw_u16(n), data_raw, raw(4L - n))
  } else {
    pad <- (8L - (n %% 8L)) %% 8L
    c(.raw_u32(datatype), .raw_u32(n), data_raw, raw(pad))
  }
}

# Array flags subelement (miUINT32, 8 bytes of data)
.mat_array_flags <- function(class_code) {
  word1 <- as.integer(class_code) # complex/global/logical flag byte is 0
  .mat_element(.MI_UINT32, c(.raw_u32(word1), .raw_u32(0L)))
}

# Dimensions subelement (miINT32)
.mat_dims <- function(dims) {
  .mat_element(.MI_INT32, .raw_i32vec(as.integer(dims)))
}

# Array name subelement (miINT8)
.mat_name <- function(name) {
  .mat_element(.MI_INT8, charToRaw(name))
}

# Build a single MATLAB char array (class mxCHAR), stored as miUTF8.
# Used both for top-level char variables and for the elements of a cell array
# (where `name` is ""). A vector of several strings becomes a multi-row char
# matrix, one string per row: rows are space-padded to a common width (as
# MATLAB's char() pads them) and the characters serialised in column-major
# order, matching how MATLAB and scipy lay out 2-D char arrays.
.mat_char_matrix <- function(name, s) {
  if (!is.character(s) || length(s) == 0L || anyNA(s)) {
    cli::cli_abort(
      "Char data for {.val {name}} must be one or more non-{.val {NA}} strings, not {.cls {class(s)}} of length {length(s)}."
    )
  }
  if (length(s) > 1L) {
    s <- enc2utf8(s)
    widths <- nchar(s, type = "chars")
    padded <- paste0(s, strrep(" ", max(widths) - widths))
    codes <- do.call(rbind, lapply(padded, utf8ToInt))
    bytes <- charToRaw(intToUtf8(as.vector(codes)))
    dims <- c(length(s), max(widths))
  } else {
    bytes <- charToRaw(enc2utf8(s))
    nch <- nchar(s, type = "chars")
    dims <- if (nch == 0L) c(0L, 0L) else c(1L, nch)
  }
  body <- c(
    .mat_array_flags(.MX_CHAR),
    .mat_dims(dims),
    .mat_name(name),
    .mat_element(.MI_UTF8, bytes)
  )
  .mat_element(.MI_MATRIX, body)
}

# Map a MATLAB numeric array class to its (data type, value encoder), matching
# the natural storage type scipy.io.savemat uses for each numpy dtype.
.mat_numeric_codec <- function(class_code) {
  switch(as.character(class_code),
    "6"  = list(mi = .MI_DOUBLE, enc = function(x) .raw_dbl(x)),                                  # mxDOUBLE
    "7"  = list(mi = .MI_SINGLE, enc = function(x) writeBin(as.double(x), raw(), size = 4L, endian = "little")), # mxSINGLE
    "8"  = list(mi = .MI_INT8,   enc = function(x) writeBin(as.integer(x), raw(), size = 1L, endian = "little")), # mxINT8
    "9"  = list(mi = .MI_UINT8,  enc = function(x) as.raw(bitwAnd(as.integer(round(x)), 255L))),  # mxUINT8
    "10" = list(mi = .MI_INT16,  enc = function(x) writeBin(as.integer(x), raw(), size = 2L, endian = "little")), # mxINT16
    "11" = list(mi = .MI_UINT16, enc = function(x) .raw_u16vec(x)),                               # mxUINT16
    # mxINT32 and mxUINT32 both arrive as doubles (see .decode_numeric) and
    # between them span values R cannot hold as integers at either end.
    "12" = list(mi = .MI_INT32,  enc = function(x) .raw_32vec_from_double(x)),                   # mxINT32
    "13" = list(mi = .MI_UINT32, enc = function(x) .raw_32vec_from_double(x)),                   # mxUINT32
    cli::cli_abort("Unsupported numeric MAT array class: {.val {class_code}}")
  )
}

# Build a top-level numeric matrix variable of the given MATLAB array class.
.mat_numeric_matrix <- function(name, mat, class_code) {
  if (!is.numeric(mat)) {
    cli::cli_abort(
      "Variable {.val {name}} must be numeric to be written as a numeric MAT array, not {.cls {class(mat)}}."
    )
  }
  if (is.null(dim(mat))) mat <- matrix(mat, ncol = 1L)
  dims <- dim(mat)
  rng <- .MX_INT_RANGES[[as.character(class_code)]]
  if (!is.null(rng)) {
    vals <- as.vector(mat)
    bad <- !is.finite(vals) | vals < rng$min | vals > rng$max | vals != round(vals)
    if (any(bad)) {
      first <- vals[which(bad)[1L]]
      cli::cli_abort(c(
        "Variable {.val {name}} holds {sum(bad)} value{?s} that cannot be stored as {.field {rng$label}}.",
        "x" = "First offending value: {.val {first}} ({rng$label} holds whole numbers from {rng$min} to {rng$max}).",
        "i" = "Write the variable as double instead, or keep its values inside the {.field {rng$label}} range."
      ))
    }
  }
  codec <- .mat_numeric_codec(class_code)
  body <- c(
    .mat_array_flags(class_code),
    .mat_dims(dims),
    .mat_name(name),
    # column-major order, matching MATLAB / numpy Fortran order
    .mat_element(codec$mi, codec$enc(as.vector(mat)))
  )
  .mat_element(.MI_MATRIX, body)
}

# Build a top-level cell array of strings (class mxCELL).
# `char_mat` is a character matrix (or vector); elements are taken in
# column-major order and each becomes an mxCHAR element.
.mat_cell_array <- function(name, char_mat) {
  # NULL slips in easily - e.g. indexing a read file for a variable it does not
  # hold - and would otherwise serialise as a plausible-looking empty cell,
  # erasing whatever the file held before.
  if (!is.character(char_mat)) {
    cli::cli_abort(
      "Cell array {.val {name}} must be character data, not {.cls {class(char_mat)}}."
    )
  }
  if (anyNA(char_mat)) {
    cli::cli_abort(
      "Cell array {.val {name}} holds {sum(is.na(char_mat))} {.val {NA}} value{?s}; every element must be a string."
    )
  }
  dims <- dim(char_mat)
  if (is.null(dims)) dims <- c(length(char_mat), 1L)
  elems <- as.vector(char_mat)
  children <- if (length(elems) == 0L) {
    raw(0)
  } else {
    do.call(c, lapply(elems, function(s) .mat_char_matrix("", s)))
  }
  body <- c(
    .mat_array_flags(.MX_CELL),
    .mat_dims(dims),
    .mat_name(name),
    children
  )
  .mat_element(.MI_MATRIX, body)
}

# Compress one already-serialised top-level miMATRIX element with zlib,
# wrapping it in a miCOMPRESSED element. R's memCompress(type = "gzip")
# emits a zlib (RFC 1950) stream, identical to Python's zlib.compress.
# Compressed elements are not padded to an 8-byte boundary (matching scipy).
.mat_compress_element <- function(element_raw) {
  z <- memCompress(element_raw, type = "gzip")
  c(.raw_u32(.MI_COMPRESSED), .raw_u32(length(z)), z)
}

# Build the 128-byte MAT-file header, mirroring scipy's text and layout.
.mat_header <- function() {
  ts <- .mat_c_time()
  text <- sprintf("MATLAB 5.0 MAT-file Platform: %s, Created on: %s", "posix", ts)
  raw_text <- charToRaw(text)
  if (length(raw_text) > 116L) raw_text <- raw_text[seq_len(116L)]
  header <- raw(128)
  header[seq_along(raw_text)] <- raw_text
  # bytes 117-124 (subsystem offset) remain 0
  header[125L] <- as.raw(0x00)
  header[126L] <- as.raw(0x01) # version 0x0100
  header[127L] <- charToRaw("I")
  header[128L] <- charToRaw("M") # endian indicator
  header
}

# C-locale timestamp matching Python's time.asctime(), e.g.
# "Sat Jun 20 00:14:34 2026" (day-of-month space-padded).
.mat_c_time <- function(now = Sys.time()) {
  wd <- c("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
  mo <- c("Jan", "Feb", "Mar", "Apr", "May", "Jun",
          "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
  lt <- as.POSIXlt(now)
  sprintf("%s %s %2d %02d:%02d:%02d %d",
          wd[lt$wday + 1L], mo[lt$mon + 1L], as.integer(lt$mday),
          as.integer(lt$hour), as.integer(lt$min), as.integer(lt$sec),
          lt$year + 1900L)
}

# ---- Variable specification constructors ----
# Each returns a list describing one MATLAB variable; consumed by write_mat_v5()
# and produced by read_mat_v5(). Numeric variables carry the MATLAB array class
# code so the exact storage type (double, uint8, uint16, int32, ...) is
# preserved on a read - write round-trip.
mat_var_numeric <- function(data, class_code) {
  list(type = "numeric", data = data, class_code = as.integer(class_code))
}
mat_var_double <- function(data) mat_var_numeric(data, .MX_DOUBLE)
mat_var_uint16 <- function(data) mat_var_numeric(data, .MX_UINT16)
mat_var_cell   <- function(data) list(type = "cell", data = data)
mat_var_char   <- function(data) list(type = "char", data = data)

# Widen a numeric variable read from a file to double when its data no longer
# fits the storage class it arrived with. Callers that modify data in place
# (ifcb_correct_annotation, ifcb_replace_mat_values) run their edits through
# this so that assigning, say, class id 300 into a classlist MATLAB stored as
# uint8 writes a double array - which MATLAB reads fine - instead of tripping
# the writer's range refusal.
.mat_widen_numeric <- function(spec) {
  if (!identical(spec$type, "numeric")) return(spec)
  rng <- .MX_INT_RANGES[[as.character(spec$class_code)]]
  if (is.null(rng)) return(spec)
  vals <- as.vector(spec$data)
  fits <- is.finite(vals) & vals >= rng$min & vals <= rng$max & vals == round(vals)
  if (!all(fits)) spec$class_code <- .MX_DOUBLE
  spec
}

#' Write a MATLAB v5 MAT-file from R (internal)
#'
#' @param filename Output path.
#' @param vars A named list of variable specifications created with
#'   `mat_var_double()`, `mat_var_uint16()`, `mat_var_cell()` or
#'   `mat_var_char()`. List names become MATLAB variable names.
#' @param do_compression Logical; compress each variable with zlib.
#' @noRd
write_mat_v5 <- function(filename, vars, do_compression = TRUE) {
  # An unnamed element would silently be skipped by the `names(vars)` loop
  # below, producing a valid-looking file with a variable missing.
  if (!is.list(vars)) {
    cli::cli_abort("{.arg vars} must be a named list of variable specifications, not {.cls {class(vars)}}.")
  }
  nms <- names(vars)
  if (length(vars) > 0L && (is.null(nms) || any(!nzchar(nms)) || anyNA(nms))) {
    cli::cli_abort("Every element of {.arg vars} must be named; the names become the MATLAB variable names.")
  }
  if (anyDuplicated(nms)) {
    cli::cli_abort("Variable names in {.arg vars} must be unique; duplicated: {.val {unique(nms[duplicated(nms)])}}.")
  }
  # Serialise to a temporary file in the same directory and rename it into place
  # only once the whole file has been written. This keeps the write atomic: if
  # serialisation aborts part-way (e.g. an unsupported variable type), an
  # existing `.mat` at `filename` is left untouched rather than truncated.
  tmp <- tempfile(tmpdir = dirname(filename), fileext = ".mat.tmp")
  con <- file(tmp, "wb")
  con_open <- TRUE
  # Clean up the temp file (and connection) unless we successfully renamed it
  # away below. `isOpen()` errors once a connection has been closed/destroyed,
  # so track the open state ourselves.
  on.exit({
    if (con_open) try(close(con), silent = TRUE)
    if (file.exists(tmp)) unlink(tmp)
  })

  writeBin(.mat_header(), con)

  for (nm in names(vars)) {
    spec <- vars[[nm]]
    element <- switch(spec$type,
      numeric = .mat_numeric_matrix(nm, spec$data, spec$class_code),
      cell    = .mat_cell_array(nm, spec$data),
      char    = .mat_char_matrix(nm, spec$data),
      cli::cli_abort("Unknown MAT variable type: {.val {spec$type}}")
    )
    if (do_compression) element <- .mat_compress_element(element)
    writeBin(element, con)
  }

  close(con)
  con_open <- FALSE
  if (!file.rename(tmp, filename)) {
    # rename can fail across devices or if the destination is locked; fall back
    # to copy + remove so the result still lands at `filename`.
    if (!file.copy(tmp, filename, overwrite = TRUE)) {
      cli::cli_abort("Failed to write MAT file: {.file {filename}}")
    }
  }

  invisible(filename)
}

# =============================================================================
# Reader
# =============================================================================

# Read a single data element starting at 1-based offset `off` within `raw`.
# Handles both the regular and "small data element" tag formats.
# Returns list(type, data, next_off).
.read_element <- function(raw, off) {
  # Out-of-range raw subsetting yields 00 bytes rather than an error, so every
  # read has to be bounds-checked explicitly; otherwise a truncated file is
  # silently zero-filled and those zeros are written back as real data.
  .need <- function(last) {
    if (last > length(raw)) {
      cli::cli_abort(c(
        "Malformed or truncated MAT-file element.",
        "i" = "Needed {last} byte{?s} but the element data ends at {length(raw)}."
      ))
    }
  }

  .need(off + 3L)
  tag4 <- readBin(raw[off:(off + 3L)], "integer", size = 4L, endian = "little")
  nbytes_small <- bitwShiftR(tag4, 16L)
  if (nbytes_small != 0L) {
    # small data element: low 16 bits = type, high 16 bits = byte count
    type <- bitwAnd(tag4, 0xFFFFL)
    nb <- nbytes_small
    .need(off + 3L + nb)
    data <- if (nb > 0L) raw[(off + 4L):(off + 3L + nb)] else raw(0)
    list(type = type, data = data, next_off = off + 8L)
  } else {
    type <- tag4
    .need(off + 7L)
    nb <- readBin(raw[(off + 4L):(off + 7L)], "integer", size = 4L, endian = "little")
    if (is.na(nb) || nb < 0L) {
      cli::cli_abort("Malformed MAT-file element: invalid byte count {.val {nb}}.")
    }
    .need(off + 7L + nb)
    data <- if (nb > 0L) raw[(off + 8L):(off + 7L + nb)] else raw(0)
    pad <- (8L - (nb %% 8L)) %% 8L
    list(type = type, data = data, next_off = off + 8L + nb + pad)
  }
}

# Map a MAT data element storage type to the array class scipy would assign
# after a loadmat -> savemat round-trip (the numpy dtype follows the data type).
.mat_data_type_to_class <- function(mi_type) {
  switch(as.character(mi_type),
    "9" = .MX_DOUBLE, # miDOUBLE -> mxDOUBLE
    "7" = 7L,         # miSINGLE -> mxSINGLE
    "1" = 8L,         # miINT8   -> mxINT8
    "2" = 9L,         # miUINT8  -> mxUINT8
    "3" = 10L,        # miINT16  -> mxINT16
    "4" = 11L,        # miUINT16 -> mxUINT16
    "5" = 12L,        # miINT32  -> mxINT32
    "6" = 13L,        # miUINT32 -> mxUINT32
    .MX_DOUBLE
  )
}

# Decode the data bytes of a numeric subelement to an R numeric vector.
.decode_numeric <- function(type, data) {
  switch(as.character(type),
    "9" = readBin(data, "double", n = length(data) %/% 8L, size = 8L, endian = "little"),       # miDOUBLE
    "7" = readBin(data, "double", n = length(data) %/% 4L, size = 4L, endian = "little"),       # miSINGLE
    # miINT32. Decoded from the raw bytes rather than by readBin(), because R
    # cannot hold -2147483648 as an integer (that bit pattern is NA_integer_),
    # so the most negative int32 would read back as missing.
    "5" = {
      v <- .u32_from_raw(data)
      v[v >= 2147483648] <- v[v >= 2147483648] - 4294967296
      v
    },
    # miUINT32. R has no unsigned 32-bit type, and values above 2147483647 do
    # not fit an R integer at all, so these stay doubles.
    "6" = .u32_from_raw(data),
    "4" = readBin(data, "integer", n = length(data) %/% 2L, size = 2L, endian = "little", signed = FALSE), # miUINT16
    "3" = readBin(data, "integer", n = length(data) %/% 2L, size = 2L, endian = "little"),      # miINT16
    "2" = as.integer(data),                                                                     # miUINT8
    # miINT8. as.integer() on raw is unsigned, which read -128 back as 128 and
    # -1 as 255. MATLAB stores a double array of small values compactly, so this
    # is how an ordinary negative value can arrive.
    "1" = readBin(data, "integer", n = length(data), size = 1L, signed = TRUE),                 # miINT8
    cli::cli_abort("Unsupported numeric MAT data type: {.val {type}}")
  )
}

# Decode the data bytes of a character subelement to an R string.
.decode_char <- function(type, data) {
  if (length(data) == 0L) return("")
  if (type == 16L || type == 1L || type == 2L) {
    # miUTF8 / miINT8 / miUINT8
    out <- rawToChar(data)
    Encoding(out) <- "UTF-8"
    out
  } else if (type == 17L || type == 4L || type == 3L) {
    # miUTF16 / miUINT16 / miINT16. MATLAB-generated char arrays are stored as
    # miUTF16; scipy round-trips them as miUINT16. Both are decoded as
    # little-endian 16-bit code units (the BMP class/label strings iRfcb deals
    # with are identical under UTF-16 and bare UCS-2).
    codes <- readBin(data, "integer", n = length(data) %/% 2L, size = 2L,
                     endian = "little", signed = FALSE)
    intToUtf8(codes)
  } else if (type == 18L) {
    # miUTF32
    codes <- readBin(data, "integer", n = length(data) %/% 4L, size = 4L,
                     endian = "little")
    intToUtf8(codes)
  } else {
    cli::cli_abort("Unsupported char MAT data type: {.val {type}}")
  }
}

# Inflate a zlib (RFC 1950) stream incrementally, returning whatever decodes
# before the stream ends. Used only as a fallback for a stream `memDecompress()`
# rejects outright, which it does for a section that stops without its
# terminator even though the deflate blocks up to that point are sound.
#
# Base R offers no incremental zlib reader, but `gzcon()` is an incremental gzip
# (RFC 1952) reader, and the two formats wrap the same deflate data: swapping the
# 2-byte zlib header for a minimal gzip header lets `gzcon()` decode it.
#
# The trailers differ, so a stream that runs to completion would fail the gzip
# checksum. That does not arise on the path this is used for: a stream stopping
# short ends at EOF before any trailer is reached, and the caller only reaches
# here when `memDecompress()` has already refused the stream. Input that is not
# deflate data at all can still make the connection layer print a checksum line
# straight to stderr, which no R-level handler can suppress, but such input
# yields nothing and the caller then aborts as before.
#
# The recovered bytes are validated by the element-length check in the caller,
# so a genuinely short read is caught there rather than returned as data.
.inflate_incremental <- function(blob) {
  if (length(blob) < 3L) return(raw(0))

  gz_header <- as.raw(c(0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff))
  con <- tryCatch(gzcon(rawConnection(c(gz_header, blob[-(1:2)]), "rb")),
                  error = function(e) NULL)
  if (is.null(con)) return(raw(0))
  on.exit(try(close(con), silent = TRUE), add = TRUE, after = FALSE)

  chunks <- list()
  n <- 0L
  repeat {
    chunk <- tryCatch(readBin(con, "raw", 1048576L), error = function(e) raw(0))
    if (length(chunk) == 0L) break
    n <- n + 1L
    chunks[[n]] <- chunk
  }

  if (n == 0L) raw(0) else do.call(c, chunks)
}

# Parse the body of a miMATRIX element (everything after its tag) into a
# variable specification. Returns list(name, spec).
.parse_matrix_body <- function(body) {
  off <- 1L

  flags_el <- .read_element(body, off); off <- flags_el$next_off
  flags_word <- readBin(flags_el$data[1:4], "integer", size = 4L, endian = "little")
  class_code <- bitwAnd(flags_word, 0xFFL)
  flag_bits <- bitwAnd(bitwShiftR(flags_word, 8L), 0xFFL)

  dims_el <- .read_element(body, off); off <- dims_el$next_off
  dims <- readBin(dims_el$data, "integer", n = length(dims_el$data) %/% 4L,
                  size = 4L, endian = "little")

  name_el <- .read_element(body, off); off <- name_el$next_off
  name <- if (length(name_el$data) == 0L) "" else rawToChar(name_el$data)

  # Refuse anything we cannot represent faithfully. Decoding these as numeric
  # would silently replace the variable with unrelated bytes, and callers write
  # the result back over the source file.
  if (!class_code %in% .MX_SUPPORTED) {
    label <- .MX_CLASS_NAMES[[as.character(class_code)]]
    cli::cli_abort(c(
      "Unsupported MATLAB array class {.val {class_code}}{if (!is.null(label)) paste0(' (', label, ')') else ''} in variable {.val {name}}.",
      "i" = "Cell arrays of strings, character arrays and numeric arrays are supported; structs, objects, sparse, string, table, categorical and 64-bit integer arrays are not."
    ))
  }
  if (bitwAnd(flag_bits, .MX_FLAG_COMPLEX) != 0L) {
    cli::cli_abort(c(
      "Variable {.val {name}} is a complex array, which is not supported.",
      "i" = "Reading it would silently discard the imaginary part."
    ))
  }
  if (bitwAnd(flag_bits, .MX_FLAG_LOGICAL) != 0L) {
    cli::cli_abort(c(
      "Variable {.val {name}} is a logical array, which is not supported.",
      "i" = "Reading it would demote it to {.code uint8}, breaking logical indexing in MATLAB."
    ))
  }
  if (length(dims) > 2L) {
    cli::cli_abort(c(
      "Variable {.val {name}} has {length(dims)} dimensions; only 2-D arrays are supported.",
      "i" = "Reading it would silently keep only the first two dimensions."
    ))
  }

  # Bound the declared element count against the bytes actually present before
  # anything allocates on the strength of it. prod() returns a double, so
  # corrupt dimensions arrive here as values far beyond what the body could
  # hold, or as NA. Left unchecked they are worse than a crash: the cell branch
  # ties up gigabytes before .read_element()'s bounds check fires, or dies on a
  # bare "vector size specified is too large" naming neither file nor variable,
  # while matrix() in the numeric branch does not even error - it allocates and
  # recycles the short data silently, fabricating values. No encoding stores an
  # element in less than a byte, and the body also carries the flags, dimensions
  # and name, so this bound never rejects an honest file.
  nel <- prod(as.double(dims))
  if (is.na(nel) || nel < 0 || nel > length(body)) {
    cli::cli_abort(c(
      "Malformed MAT-file: variable {.val {name}} declares {nel} elements.",
      "i" = "The element data is {length(body)} bytes, which cannot hold that many."
    ))
  }

  spec <- if (class_code == .MX_CELL) {
    strs <- character(nel)
    if (nel > 0L) {
      for (k in seq_len(nel)) {
        child <- .read_element(body, off); off <- child$next_off
        child_spec <- .parse_matrix_body(child$data)$spec
        # iRfcb only writes/reads cell arrays of strings. A non-char child
        # (e.g. a nested struct or numeric cell) is unsupported; fail clearly
        # rather than coercing a multi-element value and dying on assignment.
        if (!identical(child_spec$type, "char")) {
          cli::cli_abort(c(
            "Unsupported cell array element of type {.val {child_spec$type}}.",
            "i" = "Only cell arrays of character strings are supported."
          ))
        }
        strs[k] <- if (is.null(child_spec$data)) "" else child_spec$data
      }
    }
    cm <- matrix(strs, nrow = dims[1], ncol = if (length(dims) > 1) dims[2] else 1L)
    mat_var_cell(cm)
  } else if (class_code == .MX_CHAR) {
    data_el <- .read_element(body, off)
    decoded <- .decode_char(data_el$type, data_el$data)
    nr <- if (length(dims) > 0L) dims[1] else 0L
    if (nr > 1L) {
      # Multi-row char array, e.g. filelistTB in ifcb-analysis summary files
      # (one fixed-width, space-padded row per sample). The characters are
      # stored in column-major order, so string i is row i of the reshaped
      # code-point matrix. Rows keep their padding, matching scipy and R.matlab.
      codes <- utf8ToInt(decoded)
      # matrix() below recycles a short vector silently, so dimensions that
      # disagree with the decoded data would fabricate rows; refuse instead.
      if (length(codes) != nel) {
        cli::cli_abort(c(
          "Malformed MAT-file: variable {.val {name}} declares a {dims[1]}x{dims[2]} character array but carries {length(codes)} character{?s}.",
          "i" = "Reading it would recycle the data to fill the declared rows."
        ))
      }
      mat_var_char(apply(matrix(codes, nrow = nr), 1L, intToUtf8))
    } else {
      mat_var_char(decoded)
    }
  } else {
    # Any numeric array (double, single, int/uint of any width). MATLAB stores
    # numeric data compactly (e.g. a "double" array of small integers can be
    # stored as uint8), and scipy's loadmat -> savemat round-trip adopts the
    # *data* storage type rather than the (wider) array-flags class. Mirror that
    # here so class files round-trip identically.
    data_el <- .read_element(body, off)
    out_class <- .mat_data_type_to_class(data_el$type)
    vals <- if (length(data_el$data) == 0L) numeric(0) else .decode_numeric(data_el$type, data_el$data)
    nr <- if (length(dims) > 0) dims[1] else 0L
    nc <- if (length(dims) > 1) dims[2] else 1L
    # matrix() recycles rather than complaining when the data is shorter than
    # the dimensions claim, so dimensions that disagree with the payload would
    # be read back as repeated values. Refuse instead: the caller may write the
    # result straight back over the source file.
    if (length(vals) != nel) {
      cli::cli_abort(c(
        "Malformed MAT-file: variable {.val {name}} declares {nr}x{nc} but carries {length(vals)} values.",
        "i" = "Reading it would recycle the data to fill the declared dimensions."
      ))
    }
    # The 32-bit integer classes reach values R cannot hold in an integer (-2^31
    # for int32, everything above 2^31-1 for uint32), so they stay doubles.
    # Everything narrower is exact as an integer.
    vals <- if (out_class %in% c(.MX_DOUBLE, .MX_SINGLE, .MX_INT32, .MX_UINT32)) {
      as.double(vals)
    } else {
      as.integer(vals)
    }
    mat_var_numeric(matrix(vals, nrow = nr, ncol = nc), out_class)
  }

  list(name = name, spec = spec)
}

#' Read a MATLAB v5 MAT-file into writer-compatible specifications (internal)
#'
#' @param filename Path to the `.mat` file.
#' @return A named list of variable specifications (see the `mat_var_*`
#'   constructors) preserving file order, suitable for modification and
#'   re-writing with `write_mat_v5()`.
#' @noRd
read_mat_v5 <- function(filename) {
  raw_all <- readBin(filename, "raw", n = file.size(filename))
  n <- length(raw_all)
  pos <- 129L # skip the 128-byte header
  vars <- list()

  while (pos + 7L <= n) {
    typ <- readBin(raw_all[pos:(pos + 3L)], "integer", size = 4L, endian = "little")
    ln <- readBin(raw_all[(pos + 4L):(pos + 7L)], "integer", size = 4L, endian = "little")
    data_start <- pos + 8L

    if (is.na(ln) || ln < 0L) {
      cli::cli_abort(c(
        "Malformed {.file {basename(filename)}}: invalid element length {.val {ln}}.",
        "i" = "The file may have been written incompletely."
      ))
    }
    if (data_start + ln - 1L > n) {
      cli::cli_abort(c(
        "{.file {basename(filename)}} is truncated.",
        "i" = "An element declares {ln} byte{?s} of data but only {n - data_start + 1L} remain{?s/} in the file."
      ))
    }

    if (typ == .MI_COMPRESSED) {
      blob <- raw_all[data_start:(data_start + ln - 1L)]
      # The 4 pad bytes protect against R builds linked to zlib-ng (Fedora's
      # system zlib): its inflate answers a stream cut off before its Adler-32
      # trailer with "give me more output space" rather than an error, so
      # `memDecompress()` doubles its buffer forever until the OOM killer stops
      # the process. With the pad, the deflate data still ends inside the input
      # and the bytes after it fail the Adler-32 check, turning the runaway
      # into the ordinary error handled below. A complete stream ignores
      # trailing bytes, so intact sections decode as before.
      element <- tryCatch(
        memDecompress(c(blob, raw(4L)), type = "gzip"),
        error = function(e) {
          # Some MATLAB-written files carry a compressed section whose zlib
          # stream never reaches its terminator. `memDecompress()` is one-shot
          # and refuses the whole stream, but the deflate blocks before the end
          # are intact and hold the data. `R.matlab` and `SciPy` both decode
          # these incrementally and so read such files, so refusing them here
          # would lose files that earlier versions of iRfcb could read.
          recovered <- .inflate_incremental(blob)
          if (length(recovered) < 8L) {
            cli::cli_abort(c(
              "Could not decompress {.file {basename(filename)}}.",
              "i" = "A compressed section is truncated or corrupted (the file may have been written incompletely).",
              "x" = conditionMessage(e)
            ))
          }
          cli_warn(c(
            "{.file {basename(filename)}} contains a compressed section that ends without its stream terminator.",
            "i" = "The data was recovered by decoding the section incrementally. The file was likely written incompletely; consider rewriting it."
          ))
          recovered
        }
      )
      # element is a full miMATRIX element: strip its 8-byte tag, parse the body
      if (length(element) < 8L) {
        cli::cli_abort("Malformed {.file {basename(filename)}}: compressed section is too short to hold an element tag.")
      }
      element_type <- readBin(element[1:4], "integer", size = 4L, endian = "little")
      if (element_type != .MI_MATRIX) {
        cli::cli_abort(c(
          "Unexpected element type {.val {element_type}} inside a compressed section of {.file {basename(filename)}}.",
          "i" = "Only matrix elements are supported at the top level."
        ))
      }
      body_len <- readBin(element[5:8], "integer", size = 4L, endian = "little")
      if (is.na(body_len) || body_len < 0L || 8L + body_len > length(element)) {
        cli::cli_abort(c(
          "Malformed {.file {basename(filename)}}: a compressed element declares {body_len} byte{?s} but only {length(element) - 8L} were decompressed.",
          "i" = "The file may have been written incompletely."
        ))
      }
      parsed <- .parse_matrix_body(element[9:(8L + body_len)])
      pos <- data_start + ln # compressed elements are not 8-byte padded
    } else if (typ == .MI_MATRIX) {
      parsed <- .parse_matrix_body(raw_all[data_start:(data_start + ln - 1L)])
      pad <- (8L - (ln %% 8L)) %% 8L
      pos <- data_start + ln + pad
    } else {
      cli::cli_abort("Unexpected top-level MAT element type: {.val {typ}}")
    }

    vars[[parsed$name]] <- parsed$spec
  }

  # The loop exits when fewer than 8 bytes remain - not necessarily at the end
  # of the file. Stray trailing bytes are the start of an element that was cut
  # off mid-tag; reading such a file as a success would silently drop that
  # variable, and a subsequent write-back would make the loss permanent.
  # (pos beyond n + 1 is fine: it only means the final element's 8-byte
  # padding was omitted at EOF, which loses nothing.)
  if (pos <= n) {
    cli::cli_abort(c(
      "{.file {basename(filename)}} is truncated.",
      "x" = "{n - pos + 1L} stray byte{?s} follow{?s/} the last complete element - the start of an element that was cut off.",
      "i" = "The file may have been written incompletely."
    ))
  }

  vars
}

Try the iRfcb package in your browser

Any scripts or data that you put into this service are public.

iRfcb documentation built on Aug. 20, 2026, 1:06 a.m.