///|
priv struct NpyHeaderCursor {
bytes : Bytes
end : Int
mut position : Int
}
///|
fn NpyHeaderCursor::peek(self : NpyHeaderCursor) -> Byte {
if self.position < self.end {
self.bytes[self.position]
} else {
b'\x00'
}
}
///|
fn NpyHeaderCursor::space(self : NpyHeaderCursor) -> Unit {
while self.position < self.end {
match self.peek() {
b' ' | b'\t' | b'\r' | b'\n' => self.position = self.position + 1
_ => break
}
}
}
///|
fn NpyHeaderCursor::expect(
self : NpyHeaderCursor,
byte : Byte,
) -> Unit raise MatError {
if self.position >= self.end || self.peek() != byte {
fail("invalid-npy", self.position, "Malformed NPY header literal")
}
self.position = self.position + 1
}
///|
fn NpyHeaderCursor::literal(
self : NpyHeaderCursor,
text : Bytes,
) -> Unit raise MatError {
for byte in text {
self.expect(byte)
}
}
///|
fn NpyHeaderCursor::string(self : NpyHeaderCursor) -> String raise MatError {
let quote = self.peek()
if quote.to_int() != 39 && quote.to_int() != 34 {
fail("invalid-npy", self.position, "Expected plain ASCII NPY string")
}
self.expect(quote)
let start = self.position
while self.position < self.end && self.peek() != quote {
let byte = self.peek().to_int()
if byte < 32 || byte > 126 || byte == 92 {
fail(
"invalid-npy",
self.position,
"Only plain ASCII header strings are supported",
)
}
self.position = self.position + 1
}
let finish = self.position
self.expect(quote)
@utf8.decode(self.bytes[start:finish]) catch {
_ => {
fail("invalid-npy", start, "Invalid header string")
""
}
}
}
///|
fn NpyHeaderCursor::dimensions(
self : NpyHeaderCursor,
max_rank : Int,
) -> Array[Int] raise MatError {
self.expect(b'(')
self.space()
let shape = []
if self.peek() == b')' {
self.expect(b')')
return shape
}
while true {
if shape.length() >= max_rank {
fail("invalid-shape", self.position, "NPY rank exceeds configured limit")
}
let mut dimension = 0
let start = self.position
while self.peek() >= b'0' && self.peek() <= b'9' {
let digit = self.peek().to_int() - 48
if dimension > (2147483647 - digit) / 10 {
fail(
"invalid-shape",
self.position,
"NPY dimension exceeds signed 32-bit range",
)
}
dimension = dimension * 10 + digit
self.position = self.position + 1
}
if self.position == start {
fail("invalid-npy", start, "Expected nonnegative NPY dimension")
}
shape.push(dimension)
self.space()
if self.peek() == b')' {
if shape.length() == 1 {
fail(
"invalid-npy",
self.position,
"One-dimensional shape requires a tuple comma",
)
}
self.expect(b')')
break
}
self.expect(b',')
self.space()
if self.peek() == b')' {
self.expect(b')')
break
}
}
shape
}
///|
/// Read primitive numeric NPY v1/v2/v3, normalize endian and C/Fortran order.
/// Scalar and vector NPY shapes become [1,1] and [n,1] for MAT interoperability.
/// No Python expressions or pickle/object payloads are evaluated.
pub fn read_npy(
bytes : Bytes,
name : String,
limits? : Limits = default_limits(),
) -> NumericArray raise MatError {
validate_limits(limits)
validate_name(name, limits)
if bytes.length() > limits.max_file_bytes {
fail("file-limit", 0, "NPY input exceeds max_file_bytes")
}
if bytes.length() < 10 || bytes[0:6] != b"\x93NUMPY" {
fail("invalid-npy", 0, "Missing or truncated NPY signature")
}
let major = bytes[6].to_int()
if (major != 1 && major != 2 && major != 3) || bytes[7] != b'\x00' {
fail(
"unsupported-npy-version", 6, "Supported NPY versions are 1.0, 2.0 and 3.0",
)
}
let prefix = if major == 1 { 10 } else { 12 }
if bytes.length() < prefix {
fail("invalid-npy", 8, "Truncated NPY header length")
}
let header_size = bits_at(bytes, 8, prefix - 8, true)
if header_size == 0UL || header_size > 65536UL {
fail("npy-header-limit", 8, "Numeric NPY headers must fit 64 KiB")
}
let end = prefix + header_size.to_int()
if end > bytes.length() || end % 16 != 0 || bytes[end - 1] != b'\n' {
fail(
"invalid-npy", prefix, "Truncated, unaligned or unterminated NPY header",
)
}
let cursor : NpyHeaderCursor = { bytes, end, position: prefix, }
cursor.space()
cursor.expect(b'{')
let seen : Map[String, Bool] = Map([])
let mut descriptor : String? = None
let mut fortran : Bool? = None
let mut dimensions : Array[Int]? = None
cursor.space()
while cursor.peek() != b'}' {
let key = cursor.string()
if seen.contains(key) {
fail("invalid-npy", cursor.position, "Duplicate NPY header key")
}
seen[key] = true
cursor.space()
cursor.expect(b':')
cursor.space()
match key {
"descr" => descriptor = Some(cursor.string())
"fortran_order" =>
if cursor.peek() == b'T' {
cursor.literal(b"True")
fortran = Some(true)
} else {
cursor.literal(b"False")
fortran = Some(false)
}
"shape" => dimensions = Some(cursor.dimensions(limits.max_dimensions))
_ => fail("invalid-npy", cursor.position, "Unknown NPY header key")
}
cursor.space()
if cursor.peek() == b'}' {
break
}
cursor.expect(b',')
cursor.space()
}
cursor.expect(b'}')
cursor.space()
if cursor.position != end || seen.length() != 3 {
fail(
"invalid-npy",
cursor.position,
"Header must contain exactly descr, fortran_order and shape",
)
}
let descriptor = descriptor.unwrap_or("")
let dtype = match descriptor {
"f8" | "c16" => Float64
"f4" | "c8" => Float32
"|i1" | "i1" => Int8
"|u1" | "u1" => UInt8
"i2" => Int16
"u2" => UInt16
"i4" => Int32
"u4" => UInt32
"i8" => Int64
"u8" => UInt64
"|b1" | "b1" => Logical
_ => {
fail(
"unsupported-npy-dtype", prefix, "Only primitive numerical/logical dtypes are supported",
)
Float64
}
}
let complex = descriptor == "c8" ||
descriptor == "c16"
let encoded_descriptor = @utf8.encode(descriptor[:])
let little = encoded_descriptor[0] != b'>'
let original_shape = dimensions.unwrap_or([])
let shape = match original_shape.length() {
0 => [1, 1]
1 => [original_shape[0], 1]
_ => original_shape.copy()
}
let count = element_count(shape, limits, prefix)
let width = dtype.width()
let item_width = width * (if complex { 2 } else { 1 })
if count > (limits.max_file_bytes - end) / item_width {
fail("file-limit", end, "NPY payload exceeds configured byte limit")
}
if bytes.length() - end != count * item_width {
fail(
"payload-size", end, "NPY payload must exactly match dtype and dimensions",
)
}
let real = Array::make(count * width, b'\x00')
let imag = if complex { Array::make(count * width, b'\x00') } else { [] }
let fortran = fortran.unwrap_or(false)
let c_strides = Array::make(shape.length(), 1)
if count > 0 {
for k = shape.length() - 2; k >= 0; k = k - 1 {
c_strides[k] = c_strides[k + 1] * shape[k + 1]
}
}
for index = 0; index < count; index = index + 1 {
let mut source = index
if !fortran {
let mut remainder = index
source = 0
for k = 0; k < shape.length(); k = k + 1 {
source = source + remainder % shape[k] * c_strides[k]
remainder = remainder / shape[k]
}
}
for j = 0; j < width; j = j + 1 {
let offset = end +
source * item_width +
(if little { j } else { width - 1 - j })
let byte = bytes[offset]
real[index * width + j] = if dtype == Logical && byte != b'\x00' {
b'\x01'
} else {
byte
}
if complex {
imag[index * width + j] = bytes[offset + width]
}
}
}
raw_array(
name,
dtype,
shape,
Bytes::from_array(real),
imag?=if complex { Some(Bytes::from_array(imag)) } else { None },
limits~,
)
}