///|
pub(all) enum TableStyle {
Automatic
Incremental
Pairs
} derive(Eq, @debug.Debug, ToJson)
///|
pub(all) struct WriteOptions {
encoding : Encoding
table_style : TableStyle
points_per_line : Int32
x_factor : Double
y_factor : Double
} derive(@debug.Debug, ToJson)
///|
pub fn WriteOptions::default() -> WriteOptions {
{
encoding: Plain,
table_style: Automatic,
points_per_line: 8,
x_factor: 1.0,
y_factor: 1.0,
}
}
///|
pub extend TableStyle with Eq::{equal, not_equal}
///|
pub extend TableStyle with @debug.Debug::{to_repr}
///|
pub extend TableStyle with ToJson::{to_json}
///|
pub extend WriteOptions with @debug.Debug::{to_repr}
///|
pub extend WriteOptions with ToJson::{to_json}
///|
fn decimal(value : Double) -> String raise JcampError {
guard finite(value) else {
reject("WRITE_NUMBER", "cannot write non-finite values")
}
let raw = value.to_string()
let upper = raw[:].to_upper().to_owned()
guard upper.find("E") is Some(at) else { return raw }
let exponent = number(upper[at + 1:].to_owned()).to_int()
let negative = upper.has_prefix("-")
let mantissa = upper[if negative { 1 } else { 0 }:at].to_owned()
let before = mantissa.find(".").unwrap_or(mantissa.length())
let digits = mantissa.replace_all(old=".", new="")
let position = before + exponent
let out = StringBuilder()
if negative {
out.write_string("-")
}
if position <= 0 {
out.write_string("0.")
for _i = 0; _i < -position; _i = _i + 1 {
out.write_string("0")
}
out.write_string(digits)
} else if position >= digits.length() {
out.write_string(digits)
for _i = digits.length(); _i < position; _i = _i + 1 {
out.write_string("0")
}
} else {
out.write_string(digits[:position].to_owned())
out.write_string(".")
out.write_string(digits[position:].to_owned())
}
out.to_string()
}
///|
fn writable_record(record : Record) -> Unit raise JcampError {
let label = trim(record.label)
guard label != "" &&
!label.contains("=") &&
!label.contains("#") &&
!label.contains("$$") &&
label.iter().all(c => c >= ' ' && c != '\u007f') else {
reject(
"WRITE_LABEL",
"record label contains a delimiter or control character",
line=record.line,
)
}
guard record.value
.iter()
.all(c => c == '\n' || c == '\t' || (c >= ' ' && c != '\u007f')) &&
!record.value.contains("$$") else {
reject(
"WRITE_VALUE",
"record value contains a control or comment delimiter",
line=record.line,
)
}
for line in record.value.split("\n") {
guard !trim(line.to_owned()).has_prefix("##") else {
reject(
"WRITE_INJECTION",
"continuation value would inject a label",
line=record.line,
)
}
}
}
///|
fn append_record(out : StringBuilder, record : Record) -> Unit raise JcampError {
writable_record(record)
out.write_string("##" + trim(record.label) + "=" + record.value + "\n")
}
///|
fn checked_spectrum(spectrum : Spectrum) -> Unit raise JcampError {
guard trim(spectrum.title) != "" &&
trim(spectrum.x_units) != "" &&
trim(spectrum.y_units) != "" else {
reject("SPECTRUM_METADATA", "title and axis units must be nonempty")
}
let kind = canonical_label(spectrum.data_type)
guard kind.has_suffix("SPECTRUM") || kind.contains("PEAK") else {
reject(
"UNSUPPORTED_DATA_TYPE", "only one-dimensional real spectra and peak tables are interpreted",
)
}
guard spectrum.records.length() <= Limits::default().max_records else {
reject("LIMIT_RECORDS", "constructed metadata exceeds record budget")
}
let mut chars = 0
for
record in spectrum.records +
[
{ label: "TITLE", value: spectrum.title, line: 0, },
{ label: "DATA TYPE", value: spectrum.data_type, line: 0, },
{ label: "XUNITS", value: spectrum.x_units, line: 0, },
{ label: "YUNITS", value: spectrum.y_units, line: 0, },
] {
writable_record(record)
let size = record.label.length() + record.value.length()
guard size <= Limits::default().max_chars - chars else {
reject("LIMIT_TEXT", "constructed metadata exceeds text budget")
}
chars += size
for line in record.value.split("\n") {
guard line.length() + record.label.length() + 3 <=
Limits::default().max_line_chars else {
reject("LIMIT_LINE", "constructed record exceeds line budget")
}
}
}
guard !spectrum.points.is_empty() &&
spectrum.points.length() <= Limits::default().max_points else {
reject("POINT_COUNT", "spectrum point count is outside supported budget")
}
for p in spectrum.points {
guard finite(p.x) else { reject("X_RANGE", "X must be finite") }
if p.y is Some(y) {
guard finite(y) else { reject("Y_RANGE", "Y must be finite or missing") }
}
}
}
///|
fn uniform_step(points : Array[Point]) -> Double? {
if points.is_empty() {
return None
}
if points.length() == 1 {
return Some(0.0)
}
let first = points[0].x
let step = (points[points.length() - 1].x - first) /
(points.length() - 1).to_double()
if !finite(step) || step == 0.0 {
return None
}
let precise = {
..ParseOptions::default(),
relative_tolerance: 0.000000000001,
absolute_tolerance: 0.000000000001,
}
for i, point in points {
let expected = first + i.to_double() * step
if !finite(expected) || !close(point.x, expected, precise) {
return None
}
}
Some(step)
}
///|
fn remove_records(
records : Array[Record],
labels : Array[String],
) -> Array[Record] {
records.filter(r => !labels.contains(canonical_label(r.label)))
}
///|
fn write_records(
spectrum : Spectrum,
options : WriteOptions,
incremental : Bool,
) -> Array[Record] raise JcampError {
let mut records = remove_records(spectrum.records, [
"TITLE", "JCAMPDX", "DATATYPE", "DATACLASS", "END", "BLOCKS", "XYDATA", "XYPOINTS",
"XYPAIRS", "PEAKTABLE", "DATATABLE", "NPOINTS", "FIRSTX", "LASTX", "FIRSTY",
"LASTY", "MINX", "MAXX", "MINY", "MAXY", "DELTAX", "XFACTOR", "YFACTOR", "XUNITS",
"YUNITS",
])
let mut x_min = spectrum.points[0].x
let mut x_max = x_min
let mut y_min : Double? = None
let mut y_max : Double? = None
for p in spectrum.points {
if p.x < x_min {
x_min = p.x
}
if p.x > x_max {
x_max = p.x
}
if p.y is Some(y) {
y_min = match y_min {
Some(v) => Some(if y < v { y } else { v })
None => Some(y)
}
y_max = match y_max {
Some(v) => Some(if y > v { y } else { v })
None => Some(y)
}
}
}
let first = spectrum.points[0]
let last = spectrum.points[spectrum.points.length() - 1]
let prefix : Array[Record] = [
{ label: "TITLE", value: spectrum.title, line: 0, },
{ label: "JCAMP-DX", value: "5.01", line: 0, },
{ label: "DATA TYPE", value: spectrum.data_type, line: 0, },
{
label: "DATA CLASS",
value: if incremental {
"XYDATA"
} else {
"XYPOINTS"
},
line: 0,
},
]
records = prefix + records
records = set_record(records, "XUNITS", spectrum.x_units)
records = set_record(records, "YUNITS", spectrum.y_units)
records = set_record(records, "XFACTOR", decimal(options.x_factor))
records = set_record(records, "YFACTOR", decimal(options.y_factor))
records = set_record(records, "FIRSTX", decimal(first.x))
records = set_record(records, "LASTX", decimal(last.x))
records = set_record(records, "NPOINTS", spectrum.points.length().to_string())
records = set_record(records, "MINX", decimal(x_min))
records = set_record(records, "MAXX", decimal(x_max))
if first.y is Some(y) {
records = set_record(records, "FIRSTY", decimal(y))
}
if last.y is Some(y) {
records = set_record(records, "LASTY", decimal(y))
}
if y_min is Some(y) {
records = set_record(records, "MINY", decimal(y))
}
if y_max is Some(y) {
records = set_record(records, "MAXY", decimal(y))
}
if incremental && uniform_step(spectrum.points) is Some(step) {
records = set_record(records, "DELTAX", decimal(step))
}
records
}
///|
fn asdf_integer(value : Double, factor : Double) -> Double raise JcampError {
let raw = value / factor
guard finite(raw) && raw.abs() <= 9007199254740991.0 else {
reject("WRITE_ASDF_RANGE", "ASDF ordinate exceeds exact integer range")
}
let integer = raw.round()
let reconstructed = integer * factor
let precise = {
..ParseOptions::default(),
relative_tolerance: 0.000000000001,
absolute_tolerance: 0.000000000001,
}
guard close(value, reconstructed, precise) else {
reject(
"WRITE_ASDF_SCALE", "selected YFACTOR does not represent ordinate as an integer without material rounding",
)
}
integer
}
///|
fn coded(value : Double, difference : Bool) -> String raise JcampError {
guard finite(value) &&
value.floor() == value &&
value.abs() <= 9007199254740991.0 else {
reject(
"WRITE_ASDF_RANGE", "coded value must be an exactly representable integer",
)
}
let digits = value.abs().to_int64().to_string()
let leading = digits[0].to_int() - 48
let alphabet = if difference {
if value < 0.0 {
"%jklmnopqr"
} else {
"%JKLMNOPQR"
}
} else if value < 0.0 {
"@abcdefghi"
} else {
"@ABCDEFGHI"
}
alphabet[leading:leading + 1].to_owned() + digits[1:].to_owned()
}
///|
fn duplication(count : Int32) -> String {
let digits = count.to_string()
let first = digits[0].to_int() - 49
"STUVWXYZs"[first:first + 1].to_owned() + digits[1:].to_owned()
}
///|
fn ordinate(
value : Double?,
options : WriteOptions,
force_absolute : Bool,
previous : Double?,
) -> (String, Bool) raise JcampError {
guard value is Some(y) else { return ("?", false) }
match options.encoding {
Plain => (decimal(y / options.y_factor), false)
Packed => {
let raw = y / options.y_factor
let text = decimal(raw)
(if raw >= 0.0 { "+" + text } else { text }, false)
}
Squeezed => (coded(asdf_integer(y, options.y_factor), false), false)
Difference => {
let raw = asdf_integer(y, options.y_factor)
if !force_absolute && previous is Some(previous) {
let delta = raw - asdf_integer(previous, options.y_factor)
(coded(delta, true), true)
} else {
(coded(raw, false), false)
}
}
}
}
///|
fn append_encoded(
out : StringBuilder,
tokens : Array[(String, Bool)],
encoding : Encoding,
) -> Unit {
let mut i = 0
while i < tokens.length() {
let (token, delta) = tokens[i]
if encoding == Plain && i > 0 {
out.write_string(" ")
}
out.write_string(token)
let mut run = 1
if encoding == Difference && delta {
while i + run < tokens.length() && tokens[i + run] == (token, true) {
run += 1
}
if run > 1 {
out.write_string(duplication(run))
}
}
i += run
}
}
///|
pub fn write_spectrum(
spectrum : Spectrum,
options~ : WriteOptions = WriteOptions::default(),
) -> String raise JcampError {
checked_spectrum(spectrum)
guard options.points_per_line >= 1 && options.points_per_line <= 1024 else {
reject("WRITE_WIDTH", "points_per_line must be between 1 and 1024")
}
guard finite(options.x_factor) &&
finite(options.y_factor) &&
options.x_factor != 0.0 &&
options.y_factor != 0.0 else {
reject("WRITE_SCALE", "scale factors must be finite and nonzero")
}
let regular = uniform_step(spectrum.points) is Some(_)
let incremental = match options.table_style {
Automatic => regular
Incremental => {
guard regular else {
reject("WRITE_AXIS", "irregular axis requires explicit pair table")
}
true
}
Pairs => false
}
guard incremental || options.encoding != Difference else {
reject("WRITE_ENCODING", "DIF output requires incremental XYDATA")
}
let out = StringBuilder()
for record in write_records(spectrum, options, incremental) {
append_record(out, record)
}
if incremental {
out.write_string("##XYDATA=(X++(Y..Y))\n")
let mut next = 0
let mut previous_difference = false
while next < spectrum.points.length() {
let begin = if previous_difference && next > 0 { next - 1 } else { next }
let end = if next + options.points_per_line < spectrum.points.length() {
next + options.points_per_line
} else {
spectrum.points.length()
}
out.write_string(
decimal(spectrum.points[begin].x / options.x_factor) + " ",
)
let tokens : Array[(String, Bool)] = []
let mut previous : Double? = None
let mut differential = false
for i = begin; i < end; i = i + 1 {
let token = ordinate(
spectrum.points[i].y,
options,
i == begin,
previous,
)
tokens.push(token)
differential = differential || token.1
previous = spectrum.points[i].y
}
append_encoded(out, tokens, options.encoding)
out.write_string("\n")
previous_difference = differential
next = end
}
} else {
out.write_string("##XYPOINTS=(XY..XY)\n")
for i, point in spectrum.points {
if i > 0 && i % options.points_per_line != 0 {
out.write_string("; ")
}
out.write_string(decimal(point.x / options.x_factor) + ",")
let (value, _) = ordinate(point.y, options, true, None)
out.write_string(value)
if (i + 1) % options.points_per_line == 0 ||
i + 1 == spectrum.points.length() {
out.write_string("\n")
}
}
}
out.write_string("##END=\n")
let result = out.to_string()
// Enforce the same profile on constructed public objects as on input files.
ignore(read_spectra(result))
result
}