Skip to main content

README.md

[![CI](https://github.com/solar05/exisbn/actions/workflows/elixir.yml/badge.svg)](https://github.com/solar05/exisbn/actions/workflows/elixir.yml)
[![codecov](https://codecov.io/gh/solar05/exisbn/graph/badge.svg?token=BJ232PTWT7)](https://codecov.io/gh/solar05/exisbn)
![Hex.pm](https://img.shields.io/hexpm/v/exisbn)
![Hex.pm](https://img.shields.io/hexpm/l/exisbn)

# Exisbn

A lightweight Elixir library for working with ISBN (International Standard Book Number) identifiers. Supports ISBN-10 and ISBN-13 validation, conversion, and metadata extraction.

## Features

- **Validation** — Check if an ISBN-10 or ISBN-13 is valid
- **Conversion** — Convert between ISBN-10 and ISBN-13 formats
- **Hyphenation** — Format ISBNs with correct hyphens
- **Check Digits** — Calculate and verify ISBN check digits
- **Metadata** — Extract publisher zones, country codes, registrant elements, and publication elements
- **Flexible Input** — Accepts ISBNs with or without hyphens

## Installation

Add `exisbn` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:exisbn, "~> 2.2"}
  ]
end
```

Then run `mix deps.get` to fetch the dependency.

## Quick Start

```elixir
# Validate an ISBN
Exisbn.valid?("978-85-359-0277-8")
# => true

# Convert ISBN-10 to ISBN-13
Exisbn.isbn10_to_13("85-359-0277-5")
# => {:ok, "9788535902778"}

# Hyphenate an ISBN
Exisbn.hyphenate("9788535902778")
# => {:ok, "978-85-359-0277-8"}

# Get the publisher zone
Exisbn.publisher_zone("9788535902778")
# => {:ok, "Brazil"}

# Get the ISO 3166-1 alpha-2 country code
Exisbn.publisher_country_code("9788535902778")
# => {:ok, "BR"}
```

### Validation Functions

#### `valid?(isbn)` — Validate ISBN

Returns `true` if the ISBN is valid (correct format, length, and check digit), `false` otherwise.

```elixir
# Valid ISBNs
Exisbn.valid?("978-5-12345-678-1")    # => true
Exisbn.valid?("85-359-0277-5")        # => true
Exisbn.valid?("9788535902778")        # => true

# Invalid ISBNs
Exisbn.valid?("978-5-12345-678")      # => false (invalid check digit)
Exisbn.valid?("85-359-0277")          # => false (incomplete)
Exisbn.valid?("invalid")              # => false
```

#### `checkdigit_correct?(isbn)` — Verify check digit only

Returns `true` if the ISBN's check digit is correct, `false` otherwise. Does not validate format or length comprehensively.

```elixir
Exisbn.checkdigit_correct?("85-359-0277-5")      # => true
Exisbn.checkdigit_correct?("978-5-12345-678-1")  # => true
Exisbn.checkdigit_correct?("978-5-12345-678")    # => false
```

#### `correct_hyphens?(isbn)` — Check formatting

Returns `true` if the ISBN is valid and has correct hyphenation, `false` otherwise.

```elixir
Exisbn.correct_hyphens?("978-85-359-0277-8")     # => true
Exisbn.correct_hyphens?("97-8853590277-8")       # => false (incorrect hyphens)
Exisbn.correct_hyphens?("0-306-40615-2")         # => true
Exisbn.correct_hyphens?("03-064-06152")          # => false
```

### Check Digit Functions

#### `isbn10_checkdigit(isbn)` / `isbn10_checkdigit!(isbn)` — Calculate ISBN-10 check digit

Returns the check digit for an ISBN-10. The check digit may be a digit or `X` (representing 10).

```elixir
# Standard form
Exisbn.isbn10_checkdigit("85-359-0277")    # => {:ok, "5"}
Exisbn.isbn10_checkdigit("5-02-013850")    # => {:ok, "9"}
Exisbn.isbn10_checkdigit("887385107")      # => {:ok, "X"}
Exisbn.isbn10_checkdigit("0str")           # => {:error, :invalid_isbn}

# Bang form (raises on error)
Exisbn.isbn10_checkdigit!("85-359-0277")   # => "5"
Exisbn.isbn10_checkdigit!("invalid")       # ** (ArgumentError) Invalid ISBN
```

#### `isbn13_checkdigit(isbn)` / `isbn13_checkdigit!(isbn)` — Calculate ISBN-13 check digit

Returns the check digit for an ISBN-13 (always a digit 0-9).

```elixir
# Standard form
Exisbn.isbn13_checkdigit("978-5-12345-678")     # => {:ok, "1"}
Exisbn.isbn13_checkdigit("978-0-306-40615")     # => {:ok, "7"}
Exisbn.isbn13_checkdigit("0str")                # => {:error, :invalid_isbn}

# Bang form
Exisbn.isbn13_checkdigit!("978-5-12345-678")    # => "1"
```

### Conversion Functions

#### `isbn10_to_13(isbn)` / `isbn10_to_13!(isbn)` — Convert ISBN-10 to ISBN-13

Converts a valid ISBN-10 to ISBN-13 format by prefixing `978` and recalculating the check digit.

```elixir
# Standard form
Exisbn.isbn10_to_13("85-359-0277-5")       # => {:ok, "9788535902778"}
Exisbn.isbn10_to_13("0306406152")          # => {:ok, "9780306406157"}
Exisbn.isbn10_to_13("invalid")             # => {:error, :invalid_isbn}

# Bang form
Exisbn.isbn10_to_13!("85-359-0277-5")      # => "9788535902778"
Exisbn.isbn10_to_13!("invalid")            # ** (ArgumentError) Invalid ISBN

# Verify conversion result
Exisbn.valid?("9788535902778")             # => true
```

#### `isbn13_to_10(isbn)` / `isbn13_to_10!(isbn)` — Convert ISBN-13 to ISBN-10

Converts a valid ISBN-13 to ISBN-10 format by removing the prefix and recalculating the check digit. Only works for ISBN-13s with `978` prefix.

```elixir
# Standard form
Exisbn.isbn13_to_10("9788535902778")       # => {:ok, "8535902775"}
Exisbn.isbn13_to_10("9780306406157")       # => {:ok, "0306406152"}
Exisbn.isbn13_to_10("str")                 # => {:error, :invalid_isbn}

# Bang form
Exisbn.isbn13_to_10!("9788535902778")      # => "8535902775"
Exisbn.isbn13_to_10!("invalid")            # ** (ArgumentError) Invalid ISBN

# Verify conversion result
Exisbn.valid?("8535902775")                # => true
```

**Note:** ISBN-13s starting with `979` cannot be converted to ISBN-10 and will return an error.

### Formatting Functions

#### `hyphenate(isbn)` / `hyphenate!(isbn)` — Format ISBN with hyphens

Returns the ISBN formatted with correct hyphens according to its publisher zone.

```elixir
# Standard form
Exisbn.hyphenate("9788535902778")          # => {:ok, "978-85-359-0277-8"}
Exisbn.hyphenate("0306406152")             # => {:ok, "0-306-40615-2"}
Exisbn.hyphenate("str")                    # => {:error, :invalid_isbn}

# Bang form
Exisbn.hyphenate!("9788535902778")         # => "978-85-359-0277-8"
Exisbn.hyphenate!("0306406152")            # => "0-306-40615-2"
```

### Metadata Extraction Functions

#### `fetch_prefix(isbn)` / `fetch_prefix!(isbn)` — Get ISBN prefix (group identifier)

Returns the ISBN prefix including group identifier (e.g., `978-85` for Brazil).

```elixir
# Standard form
Exisbn.fetch_prefix("9788535902778")       # => {:ok, "978-85"}
Exisbn.fetch_prefix("2-1234-5680-2")       # => {:ok, "978-2"}
Exisbn.fetch_prefix("str")                 # => {:error, :invalid_isbn}

# Bang form
Exisbn.fetch_prefix!("9788535902778")      # => "978-85"
Exisbn.fetch_prefix!("str")               # ** (ArgumentError) Invalid ISBN
```

#### `publisher_zone(isbn)` / `publisher_zone!(isbn)` — Get publisher zone/country

Returns the geographic zone or language group associated with the ISBN prefix.

```elixir
# Standard form
Exisbn.publisher_zone("9788535902778")     # => {:ok, "Brazil"}
Exisbn.publisher_zone("2-1234-5680-2")     # => {:ok, "French language"}
Exisbn.publisher_zone("str")               # => {:error, :invalid_isbn}

# Bang form
Exisbn.publisher_zone!("9788535902778")    # => "Brazil"
Exisbn.publisher_zone!("2-1234-5680-2")    # => "French language"
```

#### `publisher_country_code(isbn)` / `publisher_country_code!(isbn)` — Get ISO 3166-1 alpha-2 country code

Returns the two-letter ISO 3166-1 alpha-2 country code for the ISBN's registration group.
Returns `{:ok, nil}` for groups that cover multiple countries or language areas
(e.g. `978-0`/`978-1` — English language, `978-2` — French language, `978-3` — German language,
`978-5` — former U.S.S.R., `978-92` — International NGO Publishers, `978-976` — Caribbean Community).

```elixir
# Standard form
Exisbn.publisher_country_code("9788535902778")     # => {:ok, "BR"}
Exisbn.publisher_country_code("9784065393987")     # => {:ok, "JP"}
Exisbn.publisher_country_code("9780306406157")     # => {:ok, nil}  # English language group
Exisbn.publisher_country_code("str")               # => {:error, :invalid_isbn}

# Bang form
Exisbn.publisher_country_code!("9788535902778")    # => "BR"
Exisbn.publisher_country_code!("9784065393987")    # => "JP"
Exisbn.publisher_country_code!("9780306406157")    # => nil
```

#### `fetch_checkdigit(isbn)` / `fetch_checkdigit!(isbn)` — Extract check digit

Returns the check digit character from the ISBN (as a string). For ISBN-10, this may be `X`.

```elixir
# Standard form
Exisbn.fetch_checkdigit("9788535902778")   # => {:ok, "8"}
Exisbn.fetch_checkdigit("2-1234-5680-2")   # => {:ok, "2"}
Exisbn.fetch_checkdigit("887385107X")      # => {:ok, "X"}
Exisbn.fetch_checkdigit("str")             # => {:error, :invalid_isbn}

# Bang form
Exisbn.fetch_checkdigit!("9788535902778")  # => "8"
Exisbn.fetch_checkdigit!("887385107X")     # => "X"
```

#### `fetch_registrant_element(isbn)` / `fetch_registrant_element!(isbn)` — Get registrant identifier

Returns the registrant element (publisher identifier) of the ISBN.

```elixir
# Standard form
Exisbn.fetch_registrant_element("9788535902778")       # => {:ok, "359"}
Exisbn.fetch_registrant_element("978-1-86197-876-9")   # => {:ok, "86197"}
Exisbn.fetch_registrant_element("9789529351787")       # => {:ok, "93"}
Exisbn.fetch_registrant_element("str")                 # => {:error, :invalid_isbn}

# Bang form
Exisbn.fetch_registrant_element!("9788535902778")      # => "359"
Exisbn.fetch_registrant_element!("978-1-86197-876-9")  # => "86197"
```

#### `fetch_publication_element(isbn)` / `fetch_publication_element!(isbn)` — Get publication/title identifier

Returns the publication element (title/publication identifier) of the ISBN.

```elixir
# Standard form
Exisbn.fetch_publication_element("978-1-86197-876-9")  # => {:ok, "876"}
Exisbn.fetch_publication_element("9789529351787")      # => {:ok, "5178"}
Exisbn.fetch_publication_element("str")                # => {:error, :invalid_isbn}

# Bang form
Exisbn.fetch_publication_element!("978-1-86197-876-9") # => "876"
Exisbn.fetch_publication_element!("9789529351787")     # => "5178"
```

## Input Handling

The library is flexible with input formatting:

```elixir
# All these are equivalent:
Exisbn.valid?("9788535902778")             # => true
Exisbn.valid?("978-85-359-0277-8")         # => true
Exisbn.valid?("978 85 359 0277 8")         # => true

# ISBN-10 with check digit X
Exisbn.valid?("887385107X")                # => true (uppercase X)
Exisbn.valid?("887385107x")                # => true (lowercase x is normalized to X)
```

The library normalizes input by upcasing, then extracting digits and the `X` check digit character.
ISBNs with or without hyphens, spaces, or lowercase `x` are accepted.

## ISBN Specifications

### ISBN-10

- 10 characters total
- Last character may be a digit (0-9) or `X` (representing 10)
- Uses modulo 11 checksum algorithm
- Convertible to ISBN-13 by prefixing `978`

### ISBN-13

- 13 digits total
- Common prefixes: `978` or `979`
- Uses modulo 10 checksum algorithm
- ISBN-13 with `978` prefix can be converted back to ISBN-10
- ISBN-13 with `979` prefix cannot be converted to ISBN-10

## Error Handling

Standard functions return error tuples:

```elixir
case Exisbn.isbn10_to_13("invalid") do
  {:ok, converted} -> IO.puts("Converted: #{converted}")
  {:error, :invalid_isbn} -> IO.puts("Invalid ISBN")
end
```

Bang functions raise `ArgumentError` on failure:

```elixir
try do
  Exisbn.isbn10_to_13!("invalid")
rescue
  ArgumentError -> IO.puts("Invalid ISBN")
end
```

## Examples

### Validate and format an ISBN

```elixir
isbn = "978-85-359-0277-8"

if Exisbn.valid?(isbn) do
  {:ok, formatted} = Exisbn.hyphenate(isbn)
  IO.puts("Valid ISBN: #{formatted}")
else
  IO.puts("Invalid ISBN")
end
```

### Convert and extract information

```elixir
isbn10 = "85-359-0277-5"

with {:ok, isbn13} <- Exisbn.isbn10_to_13(isbn10),
     {:ok, zone} <- Exisbn.publisher_zone(isbn13),
     {:ok, country_code} <- Exisbn.publisher_country_code(isbn13),
     {:ok, prefix} <- Exisbn.fetch_prefix(isbn13) do
  IO.puts("ISBN-10: #{isbn10}")
  IO.puts("ISBN-13: #{isbn13}")
  IO.puts("Publisher Zone: #{zone}")
  IO.puts("Country Code: #{country_code}")
  IO.puts("Prefix: #{prefix}")
else
  {:error, :invalid_isbn} -> IO.puts("Invalid ISBN")
end
```

### Find ISBN details

```elixir
isbn = "978-1-86197-876-9"

with true <- Exisbn.valid?(isbn),
     {:ok, zone} <- Exisbn.publisher_zone(isbn),
     {:ok, registrant} <- Exisbn.fetch_registrant_element(isbn),
     {:ok, publication} <- Exisbn.fetch_publication_element(isbn) do
  IO.puts("Zone: #{zone}")
  IO.puts("Registrant: #{registrant}")
  IO.puts("Publication: #{publication}")
else
  _ -> IO.puts("Could not extract ISBN details")
end
```

## Documentation

Full API documentation with more examples is available at [HexDocs](https://hexdocs.pm/exisbn/Exisbn.html).

## License

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

## Contributing

Contributions are welcome! Please feel free to submit issues or pull requests on [GitHub](https://github.com/solar05/exisbn).