Metadata-Version: 2.4
Name: irspec
Version: 0.2.2
Summary: Infrared spectra for chemistry students: OMNIC .SPA and JCAMP-DX in, tidy CSVs, peak lists and report-ready figures out.
Author: Ali Perdekhan
License: MIT
Project-URL: Homepage, https://github.com/aliperdehan/irspec
Project-URL: Source, https://github.com/aliperdehan/irspec
Project-URL: Changelog, https://github.com/aliperdehan/irspec/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/aliperdehan/irspec/issues
Keywords: infrared,IR,FTIR,spectroscopy,chemistry,OMNIC,SPA,JCAMP-DX,teaching
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Education
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Environment :: Console
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy
Requires-Dist: scipy
Requires-Dist: Pillow
Provides-Extra: matplotlib
Requires-Dist: matplotlib; extra == "matplotlib"
Dynamic: license-file

# irspec

Infrared spectra for chemistry students. Put the files from the instrument, from
NIST WebBook or from SDBS in a folder, run one command, and get tidy CSVs, peak
lists and report-quality figures (PDF, PNG, TIFF, SVG) back.

![An IR spectrum of phenacetin, drawn by irspec](docs/example-phenacetin.png)

*Above: the author's own phenacetin spectrum, from `irspec session` with
`--formats png`.*

```
irspec session my_lab_folder/ --formats png,svg
```

**Contents:** [Status](#status) · [What it does](#what-it-does-and-what-it-does-not) ·
[Install](#install) · [Quick start](#quick-start) · [Inputs](#the-three-kinds-of-input) ·
[Names](#names) · [CSV files](#csv-files) · [Manifest](#the-manifest) · [Editing](#editing-the-manifest-irspec-edit) ·
[Pictures](#pictures-png-tiff-svg) · [Without TeX](#drawing-without-tex-matplotlib-and-r) ·
[PDF build](#how-the-pdf-is-made) · [All commands](#the-other-commands) ·
[Trust](#can-i-trust-the-output) · [Versioning](#versioning) · [Roadmap](#roadmap) ·
[Development](#development) · [Licence](#licence)

## Status

**Alpha, version 0.2.** The command line and the folder layout below work and are
tested, but before 1.0 a new minor version (0.3, 0.4, ...) may change them: pin
the minor version if something depends on irspec. The Python code (`import
irspec`) is not a stable interface before 1.0: use the command line. See
[Versioning](#versioning) and `CHANGELOG.md`.

## What it does, and what it does not

It does:

- read the Thermo OMNIC `.SPA`/`.SPG` files of a Nicolet iS5 FTIR (the instrument it was
  written for; other OMNIC files are read the same way, but only this one is tested),
  NIST WebBook `.jdx` (JCAMP-DX) files, and spectra that exist only as a picture
  (SDBS plots) by reading the trace off the image;
- write each spectrum as two CSVs (see [CSV files](#csv-files)), pick its peaks
  and draw it with the labels kept apart;
- put every spectrum on the same axis, **%Transmittance**: an Absorbance
  spectrum is converted on purpose, never mixed in silently;
- make a PDF with one spectrum per landscape page, and PNG/TIFF/SVG copies of
  each page named after the spectrum;
- or draw the same figures without TeX, with matplotlib or with R (ggplot2), if
  you would rather have those ([below](#drawing-without-tex-matplotlib-and-r)).

It does not interpret spectra, assign bands or identify compounds. The peak
labels are wavenumbers; deciding what a band is stays your job. Peak picking is a
convenience, and the manifest lets you correct it ([below](#the-manifest)).

## Install

Needs Python 3.9 or newer.

```
pip install irspec       # or: pipx install irspec
irspec --version         # python -m irspec --version does the same
```

From a checkout of [the repository](https://github.com/aliperdehan/irspec) instead:
`pip install .` (or `pip install -e .` while developing).

This pulls in numpy, scipy and Pillow. The rest are programs irspec calls, and
only the ones for the output you ask for are needed:

| You want | Needs | macOS | Debian/Ubuntu |
|---|---|---|---|
| a PDF | `lualatex` with pgfplots (TeX Live or MacTeX) | `brew install --cask mactex-no-gui` | `apt install texlive-full` |
| PNG, TIFF, SVG | poppler (`pdfinfo`, `pdftoppm`, `pdftocairo`) | `brew install poppler` | `apt install poppler-utils` |
| SVG with editable text | MuPDF (`mutool`) | `brew install mupdf-tools` | `apt install mupdf-tools` |
| matplotlib figures (`irspec plot`) | the matplotlib package: `pip install ".[matplotlib]"` | | |
| R figures (`irspec plot --backend r`) | R with `ggplot2`, `jsonlite`; `ragg`, `svglite` recommended | `brew install --cask r` | `apt install r-base` |
| `digitize --omnic` | `tesseract` | `brew install tesseract` | `apt install tesseract-ocr` |

The figures use the font STIX Two when it is installed. When it is not, they use
the copies bundled with irspec (see [Licence](#licence)), so a figure looks the same
on a machine that does not have the font. If a program is missing, irspec says which one
and what to install instead of failing with a traceback.

On macOS the Homebrew `python3` refuses a bare `pip install` ("externally
managed environment"); use a virtual environment
(`python3 -m venv ~/irspec-venv && ~/irspec-venv/bin/pip install .`) or `pipx`.

## Quick start

1. Make a folder and drop the files in it. Loose files are sorted for you:
   `.SPA`/`.SPG` into `SPA/`, `.jdx` into `jdx/`, anything else into `other/`.
   **Pictures are the exception**: put an SDBS plot (`.gif`, `.jpg`, `.png`,
   `.bmp`) in `img/` yourself, because a screenshot of the instrument and a
   published plot are both "an image" and irspec cannot tell which you mean.
2. Run it:

   ```
   irspec session my_lab_folder/
   ```

3. Look at the PDF it wrote, then fix whatever you disagree with in
   `manifest.json` (a title, a peak) and run the same command again. A re-run
   keeps your edits.

After a run on one `.SPA` file the folder looks like this:

```
my_lab_folder/
  SPA/PHENACETIN_CRUDE_LR1-2.SPA            the file, moved here
  csv/PHENACETIN_CRUDE_LR1-2.csv            archival CSV
  plain/PHENACETIN_CRUDE_LR1-2_plain.csv    the CSV the figure reads
  manifest.json                             titles, peaks, per-spectrum settings
  figures.json                              the figures as the drawing backends read them
  ir_my_lab_folder.md                       the figure list, for nulabreport's pdfmd
  ir_my_lab_folder.tex                      the same for plain lualatex  (--build latex)
  ir_my_lab_folder.pdf                      the figure PDF, one spectrum per page
  figures/PHENACETIN_CRUDE_LR1-2.png        with --formats (from the PDF)
  figures/matplotlib/..., figures/r/...     with irspec plot
```

The base name `ir_...` is `ir_<dd-mm>` when the folder's name contains a date
(`own-29.09` gives `ir_29-09`), else `ir_` and the folder's name.
`--md-name NAME` chooses it yourself.

### Options of `irspec session`

| Option | Meaning |
|---|---|
| `--dry-run` | print the file moves and stop |
| `--preview` | `--dry-run`, and then every spectrum drawn as text with its peaks and the names it will get; writes nothing, see [Preview a whole folder](#preview-a-whole-folder-before-filing) (takes `--width`, `--height`, `--ascii`, `--json` as `irspec preview` does) |
| `--json` | one JSON object on stdout instead of the report (the moves, every spectrum with its peaks and ymin, the files written, how the build went); the usual progress lines go to stderr. Keys: [`docs/schema.md`](docs/schema.md) |
| `--build auto\|pdfmd\|latex\|none` | how the PDF is made (`--no-pdf` is `none`); the run says which build it used, see [How the PDF is made](#how-the-pdf-is-made) |
| `--pdfmd PATH` | the `pdfmd` to use (otherwise `$PDFMD`, else `pdfmd` on the PATH) |
| `--sort time\|name` | order of new files (modification time by default) |
| `--title STEM=TITLE` | set a caption from the command line (repeatable) |
| `--md-name NAME` | base name of the `.md`/`.tex`/`.pdf` |
| `--formats LIST`, `--dpi N` | also write PNG/TIFF/SVG, see [Pictures](#pictures-png-tiff-svg) |

## The three kinds of input

| Source | Goes in | What happens |
|---|---|---|
| The lab's own instrument file (`.SPA`, `.SPG`) | `SPA/` | decoded directly (checked against a Spectragryph export to 0.0000 %T); peaks picked |
| NIST WebBook `.jdx` | `jdx/` | **Transmittance** is rescaled 0–1 → 0–100; **Absorbance** is converted to %T; **QUANT-IR gas-phase** files (absorptivity, not an absorbance) need `peak_a` or `cl` in the manifest, see below |
| A spectrum published only as a picture (SDBS) | `img/` | the trace is read off the image and drawn back over it as a check; a failed calibration or redraw check **stops the run** and names the log in `check/` |

`.jdx` files that use JCAMP's compressed number formats (SQZ/DIF/DUP) are
refused with a message rather than decoded wrongly.

### QUANT-IR gas-phase files

These carry an absorptivity (absorbance per µmol/mol per metre), not an
absorbance, and the file does not say what concentration or path length it was
measured at. irspec will not invent one. Say what to scale the strongest band to,
and say so in your caption:

```json
{"file": "jdx/1634-04-4-IR.jdx", "name": "MTBE_GAS", "peak_a": 1}
```

`"cl": <c×L in µmol/mol·m>` is the alternative when you know the product.

## Names

An instrument spectrum keeps its file's stem: `SPA/RUN3.SPA` gives
`csv/RUN3.csv` and `plain/RUN3_plain.csv`. A reference spectrum (`jdx/`, `img/`)
gets a name in capitals with underscores and no spaces, `csv/NAME.csv` and
`plain/NAME.csv` (no `_plain`; the difference is deliberate). The name comes from,
in this order:

1. `"name"` in its manifest entry;
2. `cas_names.json` in the session folder, looked up by the CAS number the file
   name starts with: `{"67-56-1": "METHANOL_GAS", "103-90-2": "ACETAMINOPHEN"}`;
3. the file's own stem (a WebBook file is called after its CAS number, so this
   is rarely what you want).

## CSV files

Two files per spectrum, because the two jobs want different things:

| | archival `csv/` | plain `plain/` |
|---|---|---|
| separator | semicolon | comma |
| decimal mark | comma | point |
| order | descending wavenumber | ascending |
| range | everything the file has | 650 cm⁻¹ and up |
| y | as decoded, never converted | %T, the figures' axis |
| use | keeping the data as it came | plotting, peak picking, correlation, other programs |

The plain CSV has a header line and two columns, wavenumber and intensity; a
spreadsheet, R, matplotlib or pgfplots reads it as it is. The archival form is
the shape Spectragryph exports.

## The manifest

`manifest.json` is the one place a session is configured. It is a list with one
entry per spectrum, in figure order, written by the first run and kept on every
re-run: your edits stay, new files are appended, vanished files are dropped.

```json
[
  {
    "file": "SPA/PHENACETIN_CRUDE_LR1-2.SPA",
    "title": "Phenacetin (crude)",
    "peaks": [[740.5, 52.3], [1241.9, 47.3], [3253.3, 75.3]],
    "ymin": -5
  }
]
```

| Key | Meaning |
|---|---|
| `file` | path of the spectrum, relative to the folder (`SPA/…`, `jdx/…`, `img/…`) |
| `title` | the caption under the figure, free text (`"Alcohol (stock)"` is fine). A new entry starts with the file's stem (an instrument spectrum), or with its compound name (`"METHYL_CINNAMATE_GAS"` gives "Methyl cinnamate gas"), or with the title inside the `.jdx`; change it here |
| `peaks` | `[wavenumber, label_y]` pairs, as picked; edit, add or delete here (or with `irspec edit … peak`). A bare number is snapped to the nearest local band. `[]` means no peaks. Delete the key to pick again |
| `axis` | y-axis label (default `Transmittance %`) |
| `ymin` | bottom of the y axis; the tool's suggestion is kept as it is, and may be negative for a strong band |
| `ymax` | top of the y axis, default the data's own maximum |
| `xmin`, `xmax` | crop the x axis (cm⁻¹; `xmax` is the left edge), default 650 and 4000; the y axis still scales to all the data unless `ymin`/`ymax` are set |
| `threshold`, `prominence`, `distance` | peak-picking sensitivity overrides, for a band at the plot edge or noise taken for bands |
| `name` | the reference name above (written back by the first run) |
| `peak_a`, `cl` | QUANT-IR files only, see above |
| `check` | pictures only: the peak table printed under the SDBS plot, typed as `"3490:84,2977:6,…"`, for the digitiser's own cross-check |
| `args` | pictures only: extra flags for `irspec digitize convert`, as a list. For a book figure: `["--palleros", "--x-ticks", "3500,3000,2500,2000,1500,1000", "--y-ends", "72.7:24.5"]`, with `check` set to the band positions printed under the troughs (`"3027.1,2948.8,..."`); those printed positions then become the peak labels, not the peak finder's |

Every key, its type, the optional `kind`, `figures.json` and the output of every `--json` command are listed in
[`docs/schema.md`](docs/schema.md), for scripts and agents.

## Editing the manifest: `irspec edit`

`irspec edit` changes `manifest.json` from the command line, so a script (or an AI agent)
can do what you do in a text editor. It touches the manifest only (`remove` also moves one
file); run `irspec session` on the folder afterwards to redraw (a re-run keeps what you set), or
add `--build` and `edit` does that for you.

```
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 set title="Phenacetin (crude)" ymin=-5
irspec edit my_lab_folder/ SPA/PHENACETIN_CRUDE_LR1-2.SPA unset ymin
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak add 1720 3000    # snapped to the nearest band
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak remove 3253
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak clear            # no peaks at all
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak auto             # pick again, drop hand edits
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 move --to 1           # first figure in the report
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 move --to last
irspec edit my_lab_folder/ 62-44-2-IR remove                            # out of the manifest, file to removed/
irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 set ymin=-5 --build auto   # ...and redraw the report
irspec edit my_lab_folder/ show                 # every entry, as text
irspec edit my_lab_folder/ show PHENACETIN_CRUDE_LR1-2 --json
```

- **Which entry.** `FILE` is the entry's `file` value (`SPA/X.SPA`), or just its file name or
  stem when only one entry has it; an ambiguous or unknown name is an error that lists the
  candidates.
- **Redrawing.** `--build auto|pdfmd|latex|none` runs `irspec session` on the folder after the
  edit (same meaning as for `session --build`; `none` rewrites the markdown and `figures.json`
  without a PDF), and `--md-name` and `--pdfmd` are passed on. Without `--build` nothing is
  redrawn, as before. An edit that changes nothing still rebuilds. If the rebuild fails the edit
  stays written, the reason is on stderr and the exit status is 3. With `--json` the one object
  gets a `"build"` key: `{"mode": ..., "ok": true}`, or `"ok": false` with an `"error"`; the
  session's progress lines go to stderr. `show` takes no `--build`.
- **Which keys.** `set` and `unset` take `title`, `axis`, `name` (text) and `ymin`, `ymax`,
  `xmin`, `xmax`, `threshold`, `prominence`, `distance` (numbers, stored as JSON numbers; a
  value that is not a number is refused). Any other key is an error naming the allowed ones.
  `xmin`/`xmax` crop the x axis and `ymax` is the top of the y axis, in the TeX figure, in `irspec plot`
  and in `irspec preview`; `set` refuses a lower limit that is not below its upper one, the default
  4000 or 650 when only one x limit is set (exit 2).
- **Peaks.** `peak add X ...` snaps each wavenumber to the nearest local band of that
  spectrum (within 15 cm⁻¹) and adds it with the label height `session` gives a band named by
  its wavenumber alone. `peak remove X ...` takes out the stored peak nearest X, if one is
  within 15 cm⁻¹. `peak clear` leaves the figure with no peaks (`"peaks": []`). `peak auto`
  picks again with the entry's `threshold`, `prominence` and `distance`, **replaces everything
  stored, hand edits included**, and lists what it dropped. Each writes an explicit `peaks`
  list, which `irspec session` keeps, so the redraw shows exactly these peaks. They start from
  what the figure shows now (an entry with no `peaks` yet starts from the automatic list), a
  peak already there or none near an X to remove changes nothing (a note goes to stderr, exit
  0), and the changes print as `peak: old -> new` (`--json`: `{"key": "peak", "old": null,
  "new": [1732.7, 93.6]}` for an added peak, the other way round for a removed one). The
  spectrum is read as `session` reads it (an own `.SPA` from the file, a reference spectrum
  from `plain/NAME.csv`); with no spectrum to read, exit 2.
- **Order and removal.** `move --to POSITION` puts the entry at that place in `manifest.json`
  (1 is first, `last` is allowed, a number past the end means last, below 1 is exit 2) and
  keeps the order of all the others; the figures in the report follow the manifest order. An
  entry already there is left alone (`--json`: `{"file": ..., "changes": [{"key": "position",
  "old": 3, "new": 1}]}`, with `changes` empty when nothing moved). `remove` takes the entry out
  of `manifest.json` **and moves its spectrum file to `removed/`** (same name; the folder is
  created). Nothing is deleted: put the file back to restore it. The move is needed because a
  file left in `SPA/`, `jdx/` or `img/` is listed again by the next `irspec session`, while
  `removed/` is not looked at, nor filed into `other/`. The `csv/` and `plain/` files made for
  it stay where they are. If `removed/NAME` already exists, nothing is changed and the exit
  status is 2 (a file is never overwritten). The entry's own `peaks`, title and other keys are
  kept nowhere else; `--json` prints the whole entry (`{"file", "removed": {...}, "moved_to":
  "removed/NAME"}`) so you can paste it back. Run `irspec session` afterwards to redraw.
- **What it writes.** The file is replaced atomically (a temporary file in the same folder,
  then a rename). Entries and keys keep their order, a new key goes last, and the file keeps
  its layout (the one `session` writes, else its own indentation, line endings and final
  newline). A command that changes nothing does not touch the file, so running it twice leaves
  identical bytes; `unset` of a key that is not there is not an error.
- **What it prints.** Each change as `key: old -> new`. With `--json`, exactly one object on
  stdout, `{"kind": "ir", "file": "SPA/X.SPA", "changes": [{"key": "ymin", "old": -5, "new": -7}]}` (`old` is
  `null` for a key that was not there, `new` is `null` after `unset`; unchanged keys are not
  listed). `show --json` prints the entry as an object, or all entries as a list.
- **Exit status.** 0 done; 2 the command cannot be carried out (unknown key, not a number, no
  such entry); 1 `manifest.json` is missing or unreadable. Errors go to stderr. It never asks
  anything.

## Looking at a spectrum in the terminal: `irspec preview`

`irspec preview FILE` prints one spectrum as plain text, so you can see what the pipeline is
going to do with it before anything is filed or built. `FILE` is a `.SPA`, a `.jdx` or a CSV.
It reads the file and writes nothing.

```
irspec preview own-29.09/SPA/PHENACETIN_CRUDE_LR1-2.SPA
irspec preview X.jdx --width 100 --height 24
irspec preview X.jdx --ascii            # . : * | instead of Braille dots
irspec preview X.jdx --json             # the numbers only, one object
```

```
PHENACETIN_CRUDE_LR1-2.SPA  --  Transmittance % against wavenumber (cm-1)

    |*********:........:***::**:::::::**|:   .:  . ..
 90 |          ** *****            Q    ||..|||:||||||.*
    |           V  UTSR                 |N||**||*|**|||
 50 |                                   P *KIG*BA*75||1
    |                                   O MJHFD 986 43
 20 |                                     L  EC      2
    |
-10 |
    ++------+-------+------+-------+------+-------+-----
     4,000        3,000  2,500   2,000  1,500        650

axis:    Transmittance %
points:  3734 (3474 between 650 and 4000 cm-1)
peaks:   31 (picked automatically); wavenumber/label height
  1 740.5/52.3   2 784.9/47.3   3 825.4/39.4
  ...
```

- **The plot.** The wavenumber axis runs 4000 to 650 from left to right, as in the figures,
  with intensity up; the trace is the part of the spectrum the figure shows, and the vertical
  extent is the figure's (the same `ymin`, so the room the peak labels need is there too). Each
  picked peak has a symbol (`1`-`9`, then `A`-`Z`, then `a`-`z`) under its band, or above it for an
  absorbance spectrum, and the list under the plot gives its wavenumber and label height. A peak
  with no free place near the curve is listed with `-` and is not marked.
- **Characters.** Braille dots (2 x 4 per character) when stdout's encoding is UTF-8, else, or
  with `--ascii`, `.` (low in its cell), `:` (middle), `*` (high) and `|` (a steep stretch).
  Everything around the trace is ASCII either way.
- **Width and height.** The terminal's width when stdout is a terminal, 80 columns when it is a
  pipe or a log (the text is otherwise the same); `--width N` and `--height N` (rows of plot,
  default 20) override both.
- **What is shown is what a session would do.** The peaks, the label heights and the `ymin` come
  from the code `irspec session` runs, and a `.jdx` is converted as the session converts it
  (fractional %T times 100, an absorbance to %T, which the text says). If the file is listed in a
  `manifest.json` in its folder or the folder above, that entry is used (its `peaks`,
  `threshold`, `prominence`, `distance`, `axis`, `ymin`, and `peak_a` or `cl` for a QUANT-IR
  file), so `irspec edit` followed by `irspec preview` shows the edit. Otherwise the peaks are the
  automatic ones.
- **`--json`** prints exactly one object and no plot: `file`, `kind` (`ir`, the plot kind), `format` (`spa`, `jdx`, `csv`: the file's),
  `axis`, `converted_from` (`"Absorbance"` or `null`), `points`, `range` (`x` and `y`, of
  the figure's window for `y`), `ymin`, `peaks` (`[wavenumber, label_y]` pairs) and
  `peaks_from` (`"automatic"` or `"manifest.json"`).
- **Exit status.** 0 done; 2 when the file cannot be read or drawn (a missing file, a file that
  is not what its name says, a QUANT-IR file with no `peak_a` or `cl` to scale it, a width below
  30 or a height below 3); the reason is on stderr. It never asks anything.
- **The limit.** The preview shows the shape, the ranges, the peak positions and which peaks are
  marked. It does not show fonts, the exact place of the labels in the figure, or its tick
  spacing. Several spectra at once (`--stack`) is not there yet.

### Preview a whole folder before filing

```
irspec session DIR --preview [--width N] [--height N] [--ascii] [--json] [--title STEM=TITLE]
```

A dry run that shows more than `--dry-run`: it prints the planned file moves exactly as
`--dry-run` does, then, for every spectrum in `DIR` (loose, or already in `SPA/` and `jdx/`), the
same text `irspec preview` prints, headed by the place and the names the file will get (`title`,
`csv/...`, `plain/...`, as `irspec session` would give them). **It writes nothing**: no folder,
no `manifest.json`, `figures.json`, `.md`, `.tex` or `.pdf`, and no file is moved. A loose file
is read from where it lies.

- An existing `manifest.json` is respected as `irspec preview` does (its peaks and titles win),
  and `--title STEM=TITLE` is honoured in the header. `--dry-run` together with `--preview` means
  the same as `--preview`; `--build`, `--formats` and `--dpi` are ignored.
- Pictures (`img/`) are listed with the names they will get and a note that `irspec session`
  digitizes them; they are not drawn here.
- `--json` prints exactly one object and nothing else on stdout:
  `{"moves": [{"from", "to"}, ...], "spectra": [...], "images": [...]}`. Each spectrum is what
  `irspec preview --json` prints for it (with `file` relative to `DIR`, where the file is now) plus
  `will_be_filed_as` (e.g. `"SPA/RUN3.SPA"`), `title`, `csv` and `plain`; a spectrum that could not
  be read has `file`, those four keys and `error`.
- **Exit status.** 0 done; 1 when one or more spectra could not be read (each is listed with its
  error and the others are still shown); 2 when `DIR` is not a folder, has no spectra, its
  `manifest.json` cannot be used, or `--width`/`--height` are too small. The reason is on stderr.

## Pictures: PNG, TIFF, SVG

`--formats` takes a comma-separated list. The pictures are made **from the figure
PDF**, one file per spectrum into `figures/`, so they all look like the PDF.

| Format | File | Made by | Notes |
|---|---|---|---|
| `png` | `NAME.png` | poppler | `--dpi` (default 300) |
| `tiff` | `NAME.tiff` | poppler | LZW-compressed, the same pixels as the PNG |
| `svg` | `NAME.svg` | poppler (`pdftocairo`) | looks exactly like the PDF anywhere; text is drawn as outlines, so it **cannot be edited as text** |
| `svg-text` | `NAME_text.svg` | MuPDF (`mutool`) | labels stay real `<text>` for Inkscape or Illustrator; the fonts' names are kept, so **STIX Two must be installed** where it is opened; TeX's word gaps are not space characters, so a label reads "Transmittance%" in an editor while it draws correctly |

A PDF you already have can be converted without a session:
`irspec export fig.pdf --formats png,tiff --dpi 600 --out pics/ --names A,B,C`
(`--names` gives the file stems in page order; without it the pages are called
`page-1`, `page-2`, …; the default folder is `figures/` beside the PDF).

## Drawing without TeX: matplotlib and R

`irspec session` also writes `figures.json`: the figures (the plain CSV, title,
y-axis label, peaks, `ymin`) in a form that knows nothing about OMNIC, JCAMP or
TeX. Two more drawing programs read it, and neither needs TeX:

```
irspec plot my_lab_folder/                                  # matplotlib: PNG
irspec plot my_lab_folder/ --formats png,svg-text,pdf       # more formats
irspec plot my_lab_folder/ --backend r --formats png,svg    # R and ggplot2
Rscript irspec.R my_lab_folder/ --formats png               # R, without Python
```

(`irspec plot --print-r-script` prints where `irspec.R` is; the script stands
alone, so a class that works in R needs only that file and a `figures.json`.)
Pictures go to `figures/matplotlib/` or `figures/r/`, so they never overwrite
the ones made from the TeX PDF. Options: `--formats`, `--out`, `--size
WIDTHxHEIGHT` in inches (default 10x6.6), `--dpi` (300), `--no-caption`.

| | matplotlib | R |
|---|---|---|
| needs | `matplotlib` | R with `ggplot2`, `jsonlite` (`ragg` for PNG/TIFF and `svglite` for SVG are used when installed) |
| formats | png, tiff, pdf, svg (text as outlines), svg-text (editable text) | the same five; pdf and svg need an R with working cairo (on macOS, CRAN's R has it only with XQuartz: `brew install --cask xquartz`), the others do not |
| font | STIX Two Text if installed, else the bundled copy; the one used is printed | the same (the R script looks for the bundled copy in the `fonts/` folder beside it, so keep that folder with it) |

All three drawings (TeX, matplotlib, R) share one set of numbers
(`src/irspec/layout.py`), measured on the TeX figure: the axis box, the x axis
from 4000 to 650 cm⁻¹, the trace (0.5 pt, `#B03A2E`), the peak labels (8 pt,
rotated, hanging below their `label_y` on a short grey leader tick) and where
the y axis starts and stops. They differ in the y tick spacing (pgfplots' rule
is its own), in antialiasing, and in where the caption sits, so *the same
figure*, not the same pixels. The TeX version remains the reference.

## How the PDF is made

`--build auto` (the default) uses:

- **`pdfmd` + nulabreport**, if both are installed: the `.md` is built by the
  author's own report pipeline (`pdfmd` is public, `pip install pdfmd-cli`;
  `nulabreport` is the author's LaTeX style for Nazarbayev University lab reports
  and is not public), so the figures come out in the house style of those reports.
  Nobody else needs this;
- otherwise **plain `lualatex`** on the generated `.tex`. That document loads the
  real `nulabreport.sty` when TeX can find it and else the bundled excerpt
  `irspec-ir.sty`, which irspec copies beside the `.tex`.

The excerpt is cut mechanically out of nulabreport.sty (its header names the
version) and draws the same page: on the author's phenacetin spectrum the two
differ in 0 pixels, and nulabreport's smoke test checks that the committed
excerpt is what its markers generate. It is a fallback, not a second
implementation, with two limits: it understands only the `captions` setting of
`\labsetup` (`furniture` is accepted and ignored), and it can lag behind the full
package. Where they disagree, the full package is right.

## The other commands

`session` is what most people want; the others are its steps, for when one step is
all you need. Each has its own `--help`.

| Command | Does | Example |
|---|---|---|
| `irspec session DIR` | the whole thing above | `irspec session own-29.09/ --formats png` |
| `irspec export PDF` | PNG/TIFF/SVG from a PDF | `irspec export fig.pdf --formats svg` |
| `irspec edit DIR` | set or unset a manifest key, edit peaks, reorder or remove an entry, show the entries ([above](#editing-the-manifest-irspec-edit)) | `irspec edit own-29.09/ X set ymin=-5` |
| `irspec preview FILE` | one spectrum as text in the terminal, with its peaks ([above](#looking-at-a-spectrum-in-the-terminal-irspec-preview)) | `irspec preview own-29.09/SPA/X.SPA --ascii` |
| `irspec plot DIR` | the figures drawn by matplotlib or R, no TeX | `irspec plot own-29.09/ --backend r --formats png` |
| `irspec spa` | `.SPA` → CSVs and peaks; `batch` for several, `plot` for a legacy matplotlib PNG/SVG | `irspec spa convert run.SPA` |
| `irspec jdx` | `.jdx` → CSVs, one file or a folder | `irspec jdx convert X.jdx --name X --out csv/X.csv --plain-out plain/X.csv` |
| `irspec peaks` | peak-pick a `.SPA`, `.jdx` or CSV (`--json` for a script) | `irspec peaks plain/X.csv` |
| `irspec digitize` | a picture → CSVs, with a redraw check | `irspec digitize convert img/X.gif --name X --out csv/X.csv --plain-out plain/X.csv` |
| `irspec a2t` | Absorbance → %T (`--to absorbance` for the other way) | `irspec a2t plain/X.csv --out plain/X_T.csv` |
| `irspec absorptivity` | QUANT-IR absorptivity → Absorbance | `irspec absorptivity X.jdx --name X --out csv/X.csv --plain-out plain/X.csv --peak-a 1` |
| `irspec corr` | Pearson r between spectra (the first is the reference) | `irspec corr plain/REF.csv plain/UNKNOWN.csv` |

`digitize` has two more layouts than SDBS: `--palleros` for book figures and
`--omnic` for OMNIC screenshots (this one reads the tick labels with tesseract).
The module docstrings are the long documentation of each tool: what was checked,
and how.

## Can I trust the output?

The tools began as the IR scripts of the author's lab-report package and were
moved here without changing what they compute. The tests
(`PYTHONPATH=src python3 -m unittest discover -s tests`) hold that in place:

- the CSV pair for the author's own phenacetin spectrum is compared byte for byte
  with the ones the original scripts made;
- sixteen commands are run by the original scripts and by the package on the same
  inputs, and the exit code, the output and every written file are compared;
- the Markdown a session writes is compared with what the original organizer
  wrote, and the plain-`lualatex` page with the full-package page, pixel for pixel;
- every exported file is compared with `pdftoppm` of its own PDF page;
- the y limits of the drawing code are held to what the TeX figure really draws
  (frame and tick positions read from the PDF on six y ranges); the matplotlib
  figure's line data, limits, label texts and positions are compared with the
  CSV and the peak list; the R script's limits and ticks are compared with the
  Python ones.

Three more checks run on the author's own coursework folders (the environment
variable `IRSPEC_LR6_IR` points at them) and are skipped everywhere else; they
are how the WebBook and SDBS paths were shown to give byte-identical CSVs.

The one sample spectrum in `tests/data/` is the author's own. No other student's
data and no instructor's material is in this repository.

## Versioning

`0.MINOR.PATCH` until 1.0, then [Semantic Versioning](https://semver.org).

- **Minor** (`0.2.x` → `0.3.0`): opens a new line, and only for something that
  could break a folder or a script someone already has: a command-line flag
  renamed or removed, a manifest key or the folder layout changed, the CSV or
  figure format changed, a `--json` key renamed or removed.
- **Patch** (`0.2.0` → `0.2.1`): everything else inside a line, new features
  included. Each change that lands gets its own patch version and its own
  `CHANGELOG.md` entry, so nothing waits unreleased. Only the newest version at
  each push is tagged and uploaded to PyPI, so a version in between may exist only
  in the changelog.
- **`SPEC_VERSION`** (in `irspec/figure.py`, now 1) is a separate integer, written
  into figure descriptions. It changes only when the manifest/figure format breaks,
  and it is frozen at 1.0.
- **0.1.0** was the first release: the TeX pipeline, the exports and the matplotlib
  and R backends. **0.2.0** added the command-line foundation: `irspec edit`,
  `irspec preview`, `--json` and [`docs/schema.md`](docs/schema.md). **1.0.0** is
  planned for when the formats have settled.
- The version lives in one place, `irspec.__version__`. A release is a git tag
  `vX.Y.Z` plus an entry in `CHANGELOG.md`. The bundled excerpt records which
  nulabreport version it was cut from.

Anything that depends on irspec before 1.0 should pin the minor version
(`irspec>=0.2,<0.3`).

## Roadmap

0.2.0 is out (PyPI, public repository). Planned, in this order; of this, only `irspec edit set`, `unset`, `show`, `peak`, `move` and `remove`, `irspec preview FILE` and `irspec session --preview`, exist so far:

- **Editing from the command line** (`irspec edit set/unset/show/peak/move/remove` and `--build` are in; `rename` is `set name=`),
  `--json` output (`session`, `peaks`, `preview`, `edit` have it) and [a written description of the manifest and the
  output](docs/schema.md), so scripts and AI agents can drive everything a person can;
- **A preview in the terminal** (`irspec preview FILE` and `irspec session --preview` are in; still
  to come: `--stack`): the spectrum drawn in text to the width of the terminal, with what the run
  is going to do;
- **`\irspectrum` reading `.SPA` and `.jdx` files directly** in LuaTeX, with automatic peaks
  and the x and y ranges chosen in the call, so a report needs no CSV;
- optional **smoothing, baseline correction and band areas**, off by default, the raw data never
  changed;
- **1.0** when the command line, the manifest and the figure format have settled;
- perhaps a small **Mac app** over the same commands, built from this repository (`app/`).

The reasons and the design are in [`docs/roadmap.md`](docs/roadmap.md).

Out of scope: MS, NMR, GC and TLC tools, identifying compounds, and anything that extracts figures
from copyrighted books.

## Development

```
PYTHONPATH=src python3 -m unittest discover -s tests
```

The original scripts the code came from are in the git history (tag `legacy-oracle`, folder
`legacy/`; see `PROVENANCE.md`); the tests compare against what they produced, recorded
in `tests/data/`. `irspec-ir.sty` is
generated and never edited: change `nulabreport.sty` and regenerate it.

### Releasing

The version bump and the `CHANGELOG.md` entry go in one commit on `main`; then the tag:
`git tag -a vX.Y.Z -m "irspec X.Y.Z" && git push origin vX.Y.Z`. The tag starts
`.github/workflows/publish.yml`, which refuses a tag that is not `irspec.__version__`,
runs the tests, builds, and uploads to PyPI by trusted publishing (no token is stored; the
PyPI project lists this repository, `publish.yml` and the environment `pypi`).

## Licence

irspec is MIT, see `LICENSE`. It bundles three unmodified fonts from the STIX
Fonts Project (STIX Two Text Regular and Italic, STIX Two Math; about 1.9 MB), which
are under the SIL Open Font License 1.1, not MIT: the notice and the license text
are in `src/irspec/data/fonts/` (`OFL.txt`, `README.txt`) and travel with every copy,
as the license asks. They are used only when STIX Two is not installed.
