// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "SMV.h" #include #include #include #include #include #include #include #include #include "../common/JFJochException.h" namespace smv { namespace { // The brace block is never long - 512 or 1024 bytes in everything seen - but HEADER_BYTES states // the real length and is itself inside the block, so read a generous prefix and trust the value. constexpr size_t PROBE_BYTES = 8192; std::vector ReadPrefix(const std::string &path, size_t bytes) { std::ifstream f(path, std::ios::binary); if (!f) throw JFJochException(JFJochExceptionCategory::MockFileOpenError, "Cannot open " + path); std::vector out(bytes); f.read(reinterpret_cast(out.data()), static_cast(bytes)); out.resize(static_cast(f.gcount())); return out; } std::string Trim(std::string s) { const auto ws = [](unsigned char c) { return std::isspace(c) != 0; }; while (!s.empty() && ws(s.front())) s.erase(s.begin()); while (!s.empty() && ws(s.back())) s.pop_back(); return s; } // "{ KEY=value; KEY=value; }" -> the pairs. Nothing here is a CIF or a JSON: a key is everything // before the first '=', a value everything to the next ';', and the block ends at the closing brace. std::optional> ParseBlock(const std::vector &buf) { if (buf.empty() || buf.front() != '{') return {}; const std::string text(reinterpret_cast(buf.data()), buf.size()); const size_t end = text.find('}'); if (end == std::string::npos) return {}; std::map kv; size_t at = 1; while (at < end) { const size_t semi = text.find(';', at); if (semi == std::string::npos || semi > end) break; const std::string item = text.substr(at, semi - at); const size_t eq = item.find('='); if (eq != std::string::npos) { std::string key = Trim(item.substr(0, eq)); std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return std::toupper(c); }); kv[key] = Trim(item.substr(eq + 1)); } at = semi + 1; } return kv; } double Num(const std::map &kv, const std::string &key, double dflt = 0.0) { const auto it = kv.find(key); if (it == kv.end()) return dflt; try { return std::stod(it->second); } catch (const std::exception &) { return dflt; } } // The first key of `keys` the header actually carries. SMV accumulated synonyms over twenty years // of writers and the spellings are not interchangeable between files, only between vendors. double NumAny(const std::map &kv, std::initializer_list keys, double dflt = 0.0) { for (const char *k : keys) { const auto it = kv.find(k); if (it != kv.end()) { try { return std::stod(it->second); } catch (const std::exception &) {} } } return dflt; } std::optional> HeaderBlock(const std::string &path) { return ParseBlock(ReadPrefix(path, PROBE_BYTES)); } Header Parse(const std::map &kv, const std::string &path) { Header h; h.raw = kv; h.nx = static_cast(Num(kv, "SIZE1")); h.ny = static_cast(Num(kv, "SIZE2")); if (h.nx <= 0 || h.ny <= 0) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, path + ": SMV header states no image size"); h.data_offset = static_cast(Num(kv, "HEADER_BYTES", 512)); const auto order = kv.find("BYTE_ORDER"); h.little_endian = (order == kv.end()) || order->second.find("little") != std::string::npos; const auto type = kv.find("TYPE"); if (type != kv.end() && type->second.find("short") == std::string::npos) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, path + ": only 16-bit SMV images are supported (TYPE=" + type->second + ")"); h.bytes_per_pixel = 2; // A single PIXEL_SIZE is the usual spelling; the split pair appears on a few writers. const double px = NumAny(kv, {"PIXEL_SIZE", "PIXEL_SIZE_X", "PIXELSIZE"}); const double py = NumAny(kv, {"PIXEL_SIZE_Y", "PIXEL_SIZE", "PIXELSIZE"}); h.pixel_x_m = px * 1e-3; // millimetres in the file h.pixel_y_m = (py > 0 ? py : px) * 1e-3; h.distance_m = NumAny(kv, {"DISTANCE", "DETECTOR_DISTANCE"}) * 1e-3; // Millimetres in the file, pixels everywhere in this program. BEAM_CENTRE is the British // spelling some writers use; ADSC's own header uses BEAM_CENTER. const double bx_mm = NumAny(kv, {"BEAM_CENTER_X", "BEAM_CENTRE_X", "BEAM_X"}); const double by_mm = NumAny(kv, {"BEAM_CENTER_Y", "BEAM_CENTRE_Y", "BEAM_Y"}); h.beam_x_px = h.pixel_x_m > 0 ? bx_mm * 1e-3 / h.pixel_x_m : 0.0; h.beam_y_px = h.pixel_y_m > 0 ? by_mm * 1e-3 / h.pixel_y_m : 0.0; h.wavelength_A = NumAny(kv, {"WAVELENGTH", "SOURCE_WAVELENGTH"}); h.angle_increment_deg = NumAny(kv, {"OSC_RANGE", "OSCILLATION_RANGE"}); // OSC_START is the angle of THIS image; PHI is where the circle stands, which is the same // thing on the single-axis goniometers that write this format, and is the only value some // writers give. h.start_angle_deg = NumAny(kv, {"OSC_START", "PHI", "START_PHI", "OMEGA"}); h.two_theta_deg = NumAny(kv, {"TWOTHETA", "TWO_THETA", "DETECTOR_2THETA"}); h.exposure_s = NumAny(kv, {"TIME", "EXPOSURE_TIME"}); const auto sn = kv.find("DETECTOR_SN"); h.detector = sn != kv.end() ? ("SMV detector S/N " + sn->second) : "SMV"; // Which circle the file says moved. OSC_AXIS names it where present; otherwise these are // single-axis collections and the name is the conventional one. const auto axis = kv.find("OSC_AXIS"); if (axis != kv.end() && !axis->second.empty()) { std::string a = axis->second; std::transform(a.begin(), a.end(), a.begin(), [](unsigned char c) { return std::tolower(c); }); h.axis_name = a; } return h; } // Extensions an SMV frame is written under. The content test below is what actually decides - .img // is also used by miniCBF and by marCCD - so this is only a cheap pre-filter. bool PlausibleExtension(const std::filesystem::path &p) { std::string ext = p.extension().string(); if (ext.empty()) return false; ext.erase(ext.begin()); if (std::all_of(ext.begin(), ext.end(), [](unsigned char c) { return std::isdigit(c); })) return true; std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); return ext == "img" || ext == "smv" || ext == "osc"; } // The same sweep rule as the marCCD reader: the last run of digits in the whole file name, so // "xtal_1_00042.img" and "xtal.042" are both handled by one rule. struct Template { std::string prefix; size_t digits = 0; std::string suffix; bool operator<(const Template &o) const { return std::tie(prefix, digits, suffix) < std::tie(o.prefix, o.digits, o.suffix); } bool Matches(const std::string &name) const { if (name.size() != prefix.size() + digits + suffix.size()) return false; if (name.compare(0, prefix.size(), prefix) != 0) return false; if (name.compare(prefix.size() + digits, suffix.size(), suffix) != 0) return false; for (size_t i = 0; i < digits; i++) if (!std::isdigit(static_cast(name[prefix.size() + i]))) return false; return true; } }; std::optional