csan-core 0.6.0

Cutter call number generation (Cutter-Sanborn and Library of Congress) for library cataloging
Documentation
# csan

Cutter call numbers for library cataloging, as a Rust library and a Typst
plugin.

`csan` derives shelf-ready cutter numbers from author names (or, for
anonymous works, titles) using two methods:

- **Cutter-Sanborn three-figure** — a lookup against the bundled
  Cutter-Sanborn table (12,000+ entries), with an optional call number
  prefix and a lowercase work mark derived from the title.
- **Library of Congress** — the LoC character-branching rules
  ([Cutter table]https://www.loc.gov/aba/pcc/053/table.html,
  [CSM G 63]https://www.loc.gov/aba/publications/FreeCSM/G063.pdf),
  producing an initial plus a digit sequence.

Input names are normalized first: whitespace and punctuation are stripped,
diacritics and ligatures are folded to ASCII (`Čapek` → `Capek`, `Strauß` →
`Strauss`), `Mc`/`M'` prefixes expand to `Mac`, and `St`/`Ste` expand to
`Saint`/`Sainte`, matching the conventions of the underlying tables.

## Workspace layout

| Path                    | Contents                                                    |
| ----------------------- | ----------------------------------------------------------- |
| `crates/csan-core`      | The library: normalization, models, generators, the table.  |
| `crates/csan`           | The `csan` command-line tool (clap).                        |
| `crates/csan-typst`     | The Typst WASM plugin wrapping the library.                 |
| `crates/csan-typst/pkg` | The Typst package (`typst.toml`, `lib.typ`, plugin binary). |
| `crates/csan-py`        | Python bindings and the PyPI package (pyo3 + maturin).      |
| `crates/csan-web`       | JavaScript/npm bindings via WebAssembly (wasm-bindgen).     |

## Rust usage

Add the library with `cargo add csan-core`. (Up to 0.5.x the library was
published as `csan`; that name now belongs to the command-line tool.)

```rust
use csan_core::{
    Attribution, Author, CutterConfig, CutterGenerator, CutterLocMethod,
    CutterSanbornMethod, Opus,
};

let entry = Attribution::Both {
    author: Author::new("Charles", "Dickens"),
    opus: Opus::new("Oliver Twist"),
};

let sanborn = CutterSanbornMethod::generate(&entry, CutterConfig::default())?;
assert_eq!(sanborn.to_string(), "D548o");

let loc = CutterLocMethod::generate(&entry, CutterConfig::default())?;
assert_eq!(loc.to_string(), "D535");
# Ok::<(), csan_core::NormalizationError>(())
```

`CutterConfig` tunes the output: `prefix_len` and `suffix_len` control the
Cutter-Sanborn prefix and work mark, and `max_len` controls the LoC digit
count (clamped to 4). Works without an author use
`Attribution::OpusOnly(...)` and are cuttered by title, including the LoC
numeral rule (`"100 best recipes"` → `A153`).

## Expected values

Generated by `cargo run --example showcase` with default configuration:

| Author              | Cutter-Sanborn | Library of Congress |
| ------------------- | -------------- | ------------------- |
| Jane Austen         | `A933`         | `A978`              |
| Eric Blair          | `B635`         | `B535`              |
| Agatha Christie     | `C555`         | `C475`              |
| Samuel Clemens      | `C625`         | `C546`              |
| Jorge De la Cruz    | `D332`         | `D453`              |
| Charles Dickens     | `D548`         | `D535`              |
| Emily Dickinson     | `D553`         | `D535`              |
| Fyodor Dostoyevsky  | `D724`         | `D678`              |
| Stephen King        | `K54`          | `K564`              |
| VERA LVX            | `L979`         | `L89`               |
| Herman Melville     | `M531`         | `M458`              |
| George Orwell       | `O79`          | `O794`              |
| William Shakespeare | `S527`         | `S535`              |
| Lord Sith           | `S623`         | `S584`              |
| Ivan Smith          | `S649`         | `S658`              |
| William Smith       | `S664`         | `S658`              |
| Leo Tolstoy         | `T654`         | `T657`              |
| Mark Twain          | `T969`         | `T835`              |
| Virginia Woolf      | `W913`         | `W665`              |
| Emile Zola          | `Z86`          | `Z653`              |

## Command line

The `csan` crate (`crates/csan`) provides the `csan` binary. Install it
with `cargo install csan`, the shell/PowerShell installers or archives
attached to each
[GitHub release](https://github.com/veralvx/csan/releases), or the Python
package below:

```sh
$ csan --cutter-sanborn --cutter-loc \
    --forename Charles --surname Dickens --title "Oliver Twist"
D548o
D535
```

Select at least one method (`--cutter-sanborn`, `--cutter-loc`) and provide
at least one non-blank input (`--forename`, `--surname`, `--title`).
`--prefix-len`/`--suffix-len` tune the Cutter-Sanborn output, `--max-len`
the LoC digits, and `--json` emits the parts as a JSON object. Errors exit
non-zero with a message on stderr.

## Python

`crates/csan-py` packages the library for Python (CPython 3.9+, abi3
wheels) under the PyPI name `csan`, including the CLI as a console
script:

```sh
pip install csan        # library + CLI
uv tool install csan    # just the CLI, isolated
```

```python
import csan
csan.cutter_sanborn(surname="Dickens", title="Oliver Twist")  # 'D548o'
csan.cutter_loc_detailed(surname="Carter")
# {'alpha_prefix': 'C', 'numeric': 378, 'formatted': 'C378'}
```

See [`crates/csan-py/README.md`](crates/csan-py/README.md) for the
full API and the maturin build/publish flow.

## JavaScript (npm)

`crates/csan-web` exposes the library to JavaScript via WebAssembly,
packaged for npm with `wasm-pack`:

```js
import { cutterLoc, cutterSanborn } from "csan-web";
cutterSanborn({ surname: "Dickens", title: "Oliver Twist" }); // "D548o"
cutterLoc({ surname: "Carter" }); // "C378"
```

See [`crates/csan-web/README.md`](crates/csan-web/README.md) for
build, examples (Node and browser), and npm publishing instructions.

## Typst usage

The Typst package lives in `crates/csan-typst/pkg` and exposes two
functions:

```typ
// Replace <version> with the installed release.
#import "@local/csan:<version>": cutter-sanborn, cutter-loc

#cutter-sanborn(
  forename: "Charles",
  surname: "Dickens",
  title: "Oliver Twist",
) // -> "D548o"

#cutter-loc(surname: "Carter") // -> "C378"
```

Both accept `forename`, `surname`, and `title` (at least one of
author/title must be non-blank; blank strings count as absent).
`cutter-sanborn` also takes `prefix-len` and `suffix-len`; `cutter-loc`
takes `max-len`. Pass `detailed: true` to receive a dictionary of the parts
(`alpha_prefix`, `numeric`, …) instead of the formatted string. Requires
Typst 0.14.2 or later.

Until the package is published to Typst Universe, install it as a
[local package](https://github.com/typst/packages#local-packages): extract
the `csan_typst-<version>.tar.gz` attached to each GitHub release into
`{data-dir}/typst/packages/local/csan/<version>/` (or copy
`crates/csan-typst/pkg` there), then import `@local/csan:<version>`. The
plugin binary alone is attached as `csan_typst.wasm`.

### Building the plugin

The compiled plugin (`csan_typst.wasm`) is committed alongside the
package, so Typst users don't need a Rust toolchain. To rebuild it after
changing the Rust code:

```sh
rustup target add wasm32-unknown-unknown
just wasm        # build + install into crates/csan-typst/pkg
just wasm-opt    # optional: shrink with binaryen's wasm-opt
just typst-test  # compile the assert-based plugin test suite
```

## Releasing

`just release <major|minor|patch>` (on an up-to-date, clean `main`) bumps
the workspace version, the `csan-core` dependency pin, and the Typst
package version, prepends the release's section to `CHANGELOG.md` with
git-cliff, then commits, tags, and pushes. It refuses a bump smaller than
the unreleased breaking changes require. The tag starts the Release workflow, which uses
[dist](https://opensource.axo.dev/cargo-dist/) to build the `csan` binaries
and installers and attaches, to the same GitHub release:

- the Typst plugin (`csan_typst.wasm`) and package
  (`csan_typst-<version>.tar.gz`);
- the Python wheels (abi3, Linux/musllinux/macOS/Windows on x86_64 and
  aarch64) and sdist;
- the Node npm package (`csan-web-<version>.tgz`).

Once the release succeeds, the Publish to PyPI workflow uploads its wheels
and sdist to [PyPI](https://pypi.org/project/csan/) (trusted publishing;
the one-time setup is described in `.github/workflows/publish-pypi.yml`).
Crates are published to crates.io by hand, library first:
`cargo publish -p csan-core && cargo publish -p csan`.

## Development

The repository pins Rust 1.97 (`rust-toolchain.toml`). `just checks` runs
the core CI gates: `cargo check`/`test`/`clippy -D warnings` (all
targets, with the `cargo`, `pedantic`, and `restriction` clippy groups
enabled), `cargo fmt --check`, `cargo doc -D warnings`, `cargo audit`,
`dprint check`, and `cog check` for Conventional Commits. CI additionally builds and smoke-tests the
Typst plugin, the Python wheel, and the npm package. Lint policy —
including the reason for every allowed lint — lives in the workspace
`Cargo.toml` and `clippy.toml`.

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT) at your option.