Parser for .spc spectroscopy files: Thermo Galactic GRAMS SPC and Shimadzu UVProbe (OLE2 and flat), and for PerkinElmer .sp files (binary and ASCII). The format is auto-detected.
$ npm i spc-parser
This package is ESM-only. It requires Node.js ≥ 20.19, ≥ 22.12, or any 24.x or later, or a bundler. CommonJS consumers must import it (no require).
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse } from 'spc-parser';
const arrayBuffer = readFileSync(join(import.meta.dirname, 'spectrum.spc'));
const result = parse(arrayBuffer);
// result is a JSON object containing everything that was parsedVersion 3 changes the output where 2.x was wrong or incomplete. Y values and the number of spectra can both change, so re-read anything derived from a 2.x result.
logs.datais removed. Despite the name it was never the log's data: it held the binary area only, decoded as a string, and dropped the disk area —wplanes.spc, whose log is 5884 disk bytes and nothing else, came back empty. The two areas are nowlogs.binaryandlogs.disk, bothUint8Array(see The log block). Old-format files havelogs: null.- Dates stored without a time zone (spec packed date, LabSpec, old format, Shimadzu flat, UVProbe OLE) no longer claim UTC: they are offset-less ISO strings, and pass
timeZoneto turn them into real instants (see Dates). Only Renishaw WiRE MS-DOS and Unix dates keepZ.date,dateLayoutandzpdIndexare omitted when absent. - Y values change where 2.x misread a subfile exponent. In a multifile a subfile exponent of 0 is a real exponent, not an absent one, and a float file exponent wins over the 16-bit flag. Measured on the fixtures:
m_evenz.spcandDEMO_3D.SPCwere 2x too small, and subfile 39 ofLC_DIODE_ARRAY.SPC64x too small.meta.exponentis the exponent used,meta.exponentYthe rawsubexp. - UVProbe OLE files return one spectrum per dataset, in storage then dataset order. 2.x returned whichever dataset the container listed first and silently dropped the rest, so both
spectra[0]and its Y values can differ; the Y label follows the measuring mode (Absorbance, Transmittance or Reflectance). - Axis labels come from the code tables and, when they apply, the custom labels; unknown codes read
Unknown (code N). - Header text (memo, resolution, source, …) is cut at its first NUL and decoded as UTF-8, else with the
encodingoption. - With the default
sortX: true,logs.imaginaryis reversed together with a decreasing X. parsethrowsSpcParserError(not a bareErrororRangeError) and returnswarnings, which is always an array.- Files that crashed with a
RangeErrorin 2.x now parse: a log area past the end of the file, a log after an XYXY directory, and a gap beforeflogoff. - Without
timeZone, a file whose date is a wall-clock reading adds one warning saying so and how to resolve it. PassingtimeZonesilences it.
| Option | Default | Description |
|---|---|---|
encoding |
'windows-1252' |
Encoding of text that is not valid UTF-8 (header memo and labels, log text, UVProbe text), e.g. 'gbk' or 'shift_jis'. Valid UTF-8 is always decoded as such. |
strict |
false |
Throw a SpcParserError with code 'STRICT' on the first anomaly instead of adding it to warnings. |
sortX |
true |
Return every spectrum sorted by increasing X. With false, the file order is kept (for Galactic files, meta.xDirection tells which it is). |
timeZone |
(none) | Zone the instrument's clock was set to, as an IANA name ('Europe/Zurich'), a fixed offset ('+01:00') or 'UTC'. Makes wall-clock dates real instants — see Dates. |
logger |
(none) | A cheminfo-types LightLogger (console, a FifoLogger, …) that receives every anomaly as it is found, in addition to warnings. |
| Export | Description |
|---|---|
parse(buffer, options) |
Parses an .spc or .sp file, auto-detecting the format. Accepts an ArrayBuffer, a typed array / DataView, a Node.js Buffer, or an IOBuffer. Returns a ParseResult. |
SpcParserError |
Error class thrown by parse, with a code of 'UNSUPPORTED_FORMAT', 'TRUNCATED' or 'STRICT'. |
getLogValue(logs, key) |
Value of a Galactic log key (case-insensitive, first match), or undefined. |
guessSpectraType(meta) |
Classifies result.meta as 'ir' | 'uv' | 'raman' | 'mass' | 'other' from the raw Galactic codes; UVProbe results are 'uv'; PerkinElmer results follow their X units (nm is 'uv', cm-1 is 'ir'). |
| types | ParseOptions (encoding, strict, sortX, timeZone, logger), ParseResult, Header, TheNewHeader, TheOldHeader, GalacticSpectrumMeta, UvProbeMeta, UvProbeDatasetMeta, ShimadzuFlatHeader, PerkinElmerMeta, PerkinElmerSpectrumMeta, PerkinElmerAsciiSpectrumMeta, PerkinElmerHistoryRecord, Axes, Axis, HeaderCodes, CustomLabels, DateLayout, XDirection, ZSpacing, SpectraType, LogBlock, LogMetadata, LogEntry, LogHistoryEntry, SpcParserErrorCode, … |
| Property | Description |
|---|---|
meta |
The Galactic Header (kind 'new' or 'old'), the Shimadzu UvProbeMeta (kind 'ole' or 'flat') or the PerkinElmerMeta (kind 'pepe' or 'peds'). |
spectra |
Array of MeasurementXY (cheminfo-types). Each spectrum owns its x and y Float64Arrays, and spectrum.meta holds its own metadata: cast it to GalacticSpectrumMeta for Galactic files to UvProbeDatasetMeta (OLE) or UvProbeMeta (flat) for UVProbe files, and to PerkinElmerSpectrumMeta (binary) or PerkinElmerAsciiSpectrumMeta (ASCII) for PerkinElmer files. |
logs |
Galactic log block, or null when the file has none (always null for the old format; absent for UVProbe and PerkinElmer). |
warnings |
Non-fatal anomalies found while parsing, always an array. Each message is written for a human; the data that could be read is still returned. |
parse throws a SpcParserError for files it cannot read, never a raw RangeError. An unsupported encoding or timeZone option throws a TypeError before the file is read.
code |
When |
|---|---|
UNSUPPORTED_FORMAT |
The buffer is not a Galactic SPC, a UVProbe OLE2 document or a Shimadzu flat file. The message starts with Unsupported SPC file and names the detected format when it is known (see Rejected formats). |
TRUNCATED |
A Galactic file shorter than its header (512 bytes, 256 for the old format) or without one complete subfile. A buffer under 32 bytes is too short to be recognised and gets UNSUPPORTED_FORMAT. |
STRICT |
Any warning, when strict: true. |
Warnings cover, among others:
- truncation:
Subfile k of n is truncated; m complete subfiles returned, trailing bytes after the last old-format subfile; - inconsistent headers:
fnsubwithout the multifile flag orfnsub0 in a multifile, TXYXYS without TXVALS,fwplanesnot dividing the number of subfiles, an XYXY directory pointing outside the file, a Wasatchsubnptstwice too large; - Y encoding: float32 subfiles in an integer file, TSPREC ignored for float data;
- dates:
fdate 0x… is not a valid date; date ignored,fdate 0x… decoded as a Unix timestamp; - the log block: offset beyond the end of the file, block or text clamped to the file, a log recovered although
flogoffis 0; - UVProbe: datasets without data, X/Y length mismatches, an unknown measuring mode, truncated streams, an interrupted scan (a dataset that stops above the first dataset of its storage, with the same start and sampling interval), an invalid Shimadzu flat date.
- time zones: a date that is a wall-clock reading while no
timeZonewas given (once per file, see Dates).
An SPC file records the wall clock the operator saw, and never its offset. The instrument writes 08:54 because that is what the lab machine's clock read; nothing in the file says which zone that was. So a decoded date cannot name an instant on its own, and by default meta.date carries no offset:
parse(buffer).meta.date; // '1995-10-28T08:54:00.000'That string is not an instant. new Date() reads it as the reader's local time, so a lab PC in Zurich and a UTC server disagree about what it means — and a Tokyo CI runner lands on the previous day. Never pass it to new Date().
Because that is easy to miss, such a file adds one warning naming the fix, and strict: true turns it into a SpcParserError:
A date of this file is a wall-clock reading with no time zone, so it names no instant: reading it
with `new Date()` applies the reader's own zone, which differs between a lab machine and a UTC
server. Pass the `timeZone` option set to the zone the instrument clock was on, such as
{ timeZone: 'Europe/Zurich' }, or { timeZone: 'UTC' } to keep the strings version 2 produced.
`meta.dateLayout` says which layout the date came from.
It is reported once per file, never for a date that already names an instant, and not at all once timeZone is set.
Give timeZone the zone the instrument's clock was set to and every date becomes an instant that is the same everywhere:
parse(buffer, { timeZone: 'Europe/Zurich' }).meta.date;
// '1995-10-28T08:54:00.000+01:00' -> 1995-10-28T07:54:00.000ZThe wall-clock digits never change; only the offset is added. Daylight saving is resolved for the date in question, so a summer and a winter file in the same zone get different offsets. timeZone: 'UTC' reproduces the strings 2.x emitted.
Two layouts are already real instants and are never shifted by timeZone:
meta.dateLayout |
Meaning | Ends with Z |
|---|---|---|
spec, labspec, old |
wall clock, offset unknown | only with timeZone |
msdos |
Renishaw WiRE writes this field in UTC | always |
unix |
a time_t, which is UTC |
always |
meta.dateLayout is the way to tell the two apart. The same applies to the per-spectrum dates from the SUBFILEx log keys, the Shimadzu flat date and the UVProbe OLE dates, which are all wall-clock readings.
meta keeps every raw header field (exponentY, numberPoints, startingX, memo, logOffset, …) and adds:
codes {experiment, x, y, z, w, post, processing}: the raw enumerated codes;axes {x, y, z, w}: {label, units, code, custom}: the label and units of each axis, from the code tables and, when the file enables them (TALABS/ZTEXTL) or the code is 0, the custom labels.xUnitsType,yUnitsType,zUnitsTypeandwAxisUnitsare the code-table texts; unknown codes giveUnknown (code N);customLabels {x, y, z}andcustomLabelsApplied;memoFragments(readable text found after the memo's NUL);date,dateLayout('spec','labspec','msdos','unix'or'old') andrawDate. Dates without a time zone are local ISO strings (2016-02-19T16:46:00.000); only MS-DOS (Renishaw WiRE) and Unix dates end withZ.dateisundefinedwhen absent or invalid;modifications [{letter, description}],postingLabel,processingLabels,invertedPeaks,drawAsSticks,isChromatogram,zpdIndex;xDirection('increasing','decreasing'or'unordered', as stored in the file),zSpacing('even','ordered'or'random', multifiles),planeSize(4D files),directoryOffsetanddirectory(XYXY files with a valid subfile directory);- old format only:
spectra(computed from the file size) andtrailingText.
Each spectrum.meta keeps the raw subheader (exponentY is the raw subexp, startingZ, wAxisValue, …) and adds exponent (the exponent actually used), isFloat, index, flags, and when defined z, zEnd, w, plane, noise, plus date and title from the SUBFILEx log keys.
A log block is four consecutive regions, each sized by a field of its 64-byte LOGSTC header. The two byte areas are separate fields of the format, not two views of the same bytes: GRAMS loaded one into memory and left the other on disk, which is where their names come from.
| Region | Size field | What it holds |
|---|---|---|
| header | — | the LOGSTC fields, as logs.meta |
| binary | logbins |
logs.binary. For NMR, the imaginary part as float32 |
| disk | logdsks |
logs.disk. Never loaded by GRAMS |
| text | at logtxto |
logs.text, and the entries parsed from it |
A file normally uses one area or the other: NMR_FID.SPC has 65536 binary bytes (16384 float32 values, one per Y point) and no disk area, while wplanes.spc has no binary area and 5884 disk bytes.
So logs holds meta (the LOGSTC fields), text (decoded and cut at its first NUL), lines, entries [{key, value, section?}], history [{date, letter?, description, program?, user?}], binary and disk (Uint8Array copies clamped to the file) and, for NMR files, imaginary (a Float64Array decoded from binary, in the order of y, reversed with it when a decreasing X is sorted).
- OLE2 files return one spectrum per dataset.
metadescribes the first dataset (numberPoints,title,date,properties) and lists all of them indatasets. Eachspectrum.metaholds its dataset'sstorageName,datasetName,measuringModeCode,operator,organization,software,softwareVersion,instrumentName,series,model,serialNumber,date,titleandproperties. The Y label follows the measuring mode (Absorbance, Transmittance or Reflectance). - Flat files return one spectrum;
meta.headerholds the raw codesfamilyFlags,modeCode,speedCode,samplingCode,displayMaxanddisplayMin. The Y label stays Absorbance.
- Binary files (signature
PEPE,kind'pepe') return one spectrum per dataset block.meta.descriptionis the file description (e.g.2D constant interval DataSet file). Eachspectrum.metaholds the datasetname,fileType,alias,samplingMethod,dataType, the unit type codesxUnitTypeandyUnitType, and the rawhistoryrecords. - ASCII exports (first line
PE … ASCII PEDS <version>,kind'peds', saved as.spor.asc) return one spectrum.meta.descriptionis that first line;spectrum.metaholdsname,creationDate,modificationDateandanalyst. The dates are kept as written: the order of day and month follows the locale of the instrument computer, someta.dateis never set. meta.xUnitsis the X axis code of the first spectrum. The axis codesnm,cm-1,A,%Tand%Rbecome Wavelength, Wavenumber, Absorbance, Transmittance and Reflectance; any other code is used as label and units.
| Variant | Notes |
|---|---|
Galactic new format, little-endian (0x4B) |
Y, XY, XYY and XYXY (with or without the subfile directory), 16-bit, 32-bit and float Y |
Galactic new format, big-endian (0x4C) |
same layouts |
Galactic old format (0x4D, Spectra Calc / Lab Calc) |
single and multifile, 16- and 32-bit Y |
| Multifiles with even, ordered or random Z, and 4D W planes | z, w and plane per spectrum |
| Galactic log block | text, key/value entries, audit history, binary and disk areas, NMR imaginary data |
| Shimadzu UVProbe OLE2 documents | every dataset, localized (e.g. GBK) text with the encoding option |
Shimadzu UVProbe flat .spc |
fixed 120-byte header |
PerkinElmer binary .sp (PEPE) |
every dataset block, UV and IR |
PerkinElmer ASCII .sp / .asc (PEDS) |
versions 1.60 and 4.00 seen |
These files also use the .spc extension. They are recognized and rejected with UNSUPPORTED_FORMAT and a message naming the format: EDAX/AMETEK EDS or XRF spectra, ORTEC/EG&G gamma spectra, ASD FieldSpec spectra, Becker & Hickl TCSPC photon streams, Bruker EPR .par parameter files, Shimadzu RF-5300 fluorescence spectra, UVProbe Kinetics (.kin) documents, gzip / R RDS objects, an unidentified encrypted family, and plain text (XY data). Headerless Bruker EPR .spc data cannot be identified and gets the generic message.
Every fixture is listed with its source, license and SHA-256 in the PROVENANCE.md files next to it (src/galactic/__tests__/data/corpus/, src/uvProbe/__tests__/data/corpus/ and src/__tests__/data/). src/__tests__/regression.test.ts parses all of them. Set SPC_CORPUS_DIR to also run src/__tests__/corpus.test.ts over a local collection of files.
SPC is a file format used in the GRAMS Software Suite for storing all kinds of spectroscopic data, including infrared spectra, Raman spectra and UV/VIS spectra. The data is stored in binary with some parts encoded in ASCII. As such, the file must be viewed in either a hex editor or any compatible spectroscopy software.
An SPC file is organized in three blocks:
- File-Header block
- Data block
- Log block
The File Header contains information about the whole file, such as how values are read, the type of format (Old, New LSB, New MSB), the structure of the data block (XY, XYY or XYXY) and the number of spectra.
The Data block contains the spectrum data and is composed of a subheader for each spectrum, the X values before the subheader if XY or XYY, else after each subheader if XYXY. After the subheader and X values come the Y values, which are read according to the method determined in the Main Header.
The Log block contains miscellaneous information that varies for each file, with a part written in ASCII and another one in binary.
The format details this parser implements are written up in docs/galactic-spec.md and docs/non-galactic-formats.md. Other useful files are in the docs folder.
The test fixtures are third-party instrument files under their own licences
(MIT, CC-BY-4.0, GPL-3.0, LGPL-3.0, NIST public domain). They are excluded from
the published package; see NOTICE.md for the attributions and the
PROVENANCE.md of each fixture folder for the per-file URL, licence and
SHA-256.