///|
/// Parse signed onset or unsigned duration, forbidding exponents/control syntax.
fn tal_number(text : String, onset : Bool) -> Double raise EdfError {
if text.length() == 0 || text.length() > 128 {
raise Invalid("invalid TAL time length")
}
let start = if onset {
if text[0] != '+' && text[0] != '-' {
raise Invalid("TAL onset requires sign")
}
1
} else {
0
}
let mut dots = 0
let mut digits = 0
for i = start; i < text.length(); i = i + 1 {
let c = text[i]
if c == '.' {
dots += 1
if dots > 1 || i == start || i == text.length() - 1 {
raise Invalid("malformed TAL decimal")
}
} else if c >= '0' && c <= '9' {
digits += 1
} else {
raise Invalid("TAL time must be a plain decimal")
}
}
if digits == 0 {
raise Invalid("empty TAL timestamp")
}
let value = number(text)
if value.abs() > 2147483647.0 {
raise Limit("TAL timestamp exceeds supported 68-year relative range")
}
value
}
///|
/// Preserve decimal spelling and grouped annotation texts.
pub fn parse_tals(data : Bytes) -> Array[Tal] raise EdfError {
if data.length() > 8388608 {
raise Limit("TAL block exceeds 8 MiB")
}
let result = []
let mut at = 0
while at < data.length() && data[at] != b'\x00' {
let start = at
while at < data.length() &&
data[at] != b'\x14' &&
data[at] != b'\x15' &&
data[at] != b'\x00' {
at += 1
}
if at == data.length() || data[at] == b'\x00' {
raise Invalid("TAL missing annotation separator")
}
let onset = text_field(data, start, at - start)
if onset.length() != at - start {
raise Invalid("spaces are forbidden in TAL onset")
}
ignore(tal_number(onset, true))
let duration = if data[at] == b'\x15' {
at += 1
let start = at
while at < data.length() && data[at] != b'\x14' && data[at] != b'\x00' {
at += 1
}
if at == data.length() || data[at] != b'\x14' {
raise Invalid("TAL duration missing separator")
}
let duration = text_field(data, start, at - start)
if duration.length() != at - start {
raise Invalid("spaces are forbidden in TAL duration")
}
ignore(tal_number(duration, false))
Some(duration)
} else {
None
}
at += 1
let texts = []
while true {
let start = at
while at < data.length() && data[at] != b'\x14' && data[at] != b'\x00' {
let b = data[at].to_int()
if b < 32 && b != 9 && b != 10 && b != 13 {
raise Invalid("forbidden TAL control character")
}
at += 1
}
if at == data.length() || data[at] != b'\x14' {
raise Invalid("TAL text requires final separator")
}
let text = @utf8.decode(data[start:at], ignore_bom=false) catch {
_ => raise Invalid("malformed UTF-8 annotation")
}
texts.push(text)
at += 1
if at >= data.length() {
raise Invalid("TAL missing zero terminator")
}
if data[at] == b'\x00' {
at += 1
break
}
}
if result.length() >= 65536 {
raise Limit("TAL count per block exceeds 65536")
}
result.push({ onset, duration, texts, })
}
while at < data.length() {
if data[at] != b'\x00' {
raise Invalid("nonzero bytes after TAL padding")
}
at += 1
}
result
}
///|
pub fn encode_tals(tals : Array[Tal], capacity : Int) -> Bytes raise EdfError {
if capacity < 0 || capacity > 8388608 {
raise Limit("TAL capacity exceeds limit")
}
let out = Array::make(capacity, b'\x00')
let mut at = 0
for tal in tals {
ignore(tal_number(tal.onset, true))
let duration = match tal.duration {
Some(d) => {
ignore(tal_number(d, false))
"\u0015" + d
}
None => ""
}
if tal.texts.is_empty() {
raise Invalid("TAL must contain at least one text, possibly empty")
}
for text in tal.texts {
for c in text.iter() {
if c.to_int() < 32 && c != '\t' && c != '\n' && c != '\r' {
raise Invalid("forbidden TAL control character")
}
}
}
let b = @utf8.encode(
tal.onset +
duration +
"\u0014" +
tal.texts.join("\u0014") +
"\u0014\u0000",
)
if b.length() > capacity - at {
raise Limit("annotations do not fit record capacity")
}
copy_bytes(out, at, b, 0, b.length())
at += b.length()
}
Bytes::from_array(out)
}
///|
/// New timestamps use the common EDF library resolution of 100 ns.
fn time_text(value : Double, signed : Bool) -> String raise EdfError {
if !finite(value) || value.abs() > 2147483647.0 || (!signed && value < 0.0) {
raise Invalid("invalid timestamp")
}
let ticks = (value.abs() * 10000000.0).round().to_int64()
if (ticks.to_double() / 10000000.0 - value.abs()).abs() > 1.0e-8 {
raise Unsupported("timestamp cannot be represented at 100 ns precision")
}
let whole = (ticks / 10000000L).to_string()
let frac = ticks % 10000000L
let tail = if frac == 0L {
""
} else {
let padded = (10000000L + frac).to_string()
let mut end = padded.length()
while end > 1 && padded[end - 1] == '0' {
end -= 1
}
"." + padded[1:end].to_owned()
}
(if value < 0.0 { "-" } else if signed { "+" } else { "" }) + whole + tail
}