defmodule Exisbn do
require Integer
alias Exisbn.Regions
@non_isbn_chars ~r/[^0-9X]/
@moduledoc """
Documentation for `Exisbn`.
"""
@doc """
Takes an ISBN 10 code as string, returns its check digit.
## Examples
iex> Exisbn.isbn10_checkdigit("85-359-0277")
{:ok, "5"}
iex> Exisbn.isbn10_checkdigit("5-02-013850")
{:ok, "9"}
iex> Exisbn.isbn10_checkdigit("0str")
{:error, :invalid_isbn}
iex> Exisbn.isbn10_checkdigit("887385107")
{:ok, "X"}
"""
@spec isbn10_checkdigit(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn10_checkdigit(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 8..10 do
case calculate_isbn10_checkdigit(isbn) do
{:ok, digit} -> {:ok, digit}
{:error, _} -> {:error, :invalid_isbn}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn10_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.isbn10_checkdigit!("85-359-0277")
"5"
iex> Exisbn.isbn10_checkdigit!("5-02-013850")
"9"
iex> Exisbn.isbn10_checkdigit!("0str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.isbn10_checkdigit!("887385107")
"X"
"""
@spec isbn10_checkdigit!(String.t()) :: String.t()
def isbn10_checkdigit!(isbn) when is_bitstring(isbn) do
case isbn10_checkdigit(isbn) do
{:ok, digit} -> digit
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN 13 code as string, returns its check digit.
## Examples
iex> Exisbn.isbn13_checkdigit("978-5-12345-678")
{:ok, "1"}
iex> Exisbn.isbn13_checkdigit("978-0-306-40615")
{:ok, "7"}
iex> Exisbn.isbn13_checkdigit("0str")
{:error, :invalid_isbn}
"""
@spec isbn13_checkdigit(binary) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn13_checkdigit(isbn) when is_bitstring(isbn) do
if String.length(normalize(isbn)) in 11..13 do
case calculate_isbn13_checkdigit(isbn) do
{:ok, digit} -> {:ok, digit}
{:error, _} -> {:error, :invalid_isbn}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn13_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.isbn13_checkdigit!("978-5-12345-678")
"1"
iex> Exisbn.isbn13_checkdigit!("978-0-306-40615")
"7"
iex> Exisbn.isbn13_checkdigit!("0str")
** (ArgumentError) Invalid ISBN
"""
@spec isbn13_checkdigit!(binary) :: String.t()
def isbn13_checkdigit!(isbn) when is_bitstring(isbn) do
case isbn13_checkdigit(isbn) do
{:ok, digit} -> digit
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN (10 or 13) and checks its validity by its check digit
## Examples
iex> Exisbn.checkdigit_correct?("85-359-0277-5")
true
iex> Exisbn.checkdigit_correct?("978-5-12345-678-1")
true
iex> Exisbn.checkdigit_correct?("978-5-12345-678")
false
"""
@spec checkdigit_correct?(String.t()) :: boolean
def checkdigit_correct?(isbn) when is_bitstring(isbn) do
normalized = normalize(isbn)
result =
if String.length(normalized) == 10 do
isbn10_checkdigit(normalized)
else
isbn13_checkdigit(normalized)
end
case result do
{:ok, digit} -> digit == String.last(normalized)
{:error, _} -> false
end
end
def checkdigit_correct?(_), do: false
@doc """
Takes an ISBN (10 or 13) and checks its validity by checking the checkdigit, length and characters.
## Examples
iex> Exisbn.valid?("978-5-12345-678-1")
true
iex> Exisbn.valid?("978-5-12345-678")
false
iex> Exisbn.valid?("85-359-0277-5")
true
iex> Exisbn.valid?("85-359-0277")
false
"""
@spec valid?(String.t()) :: boolean
def valid?(isbn) when is_bitstring(isbn) do
normalized = normalize(isbn)
correct_normalized_length?(normalized) and checkdigit_correct_for_normalized?(normalized)
end
def valid?(_), do: false
@doc """
Takes an ISBN 10 and converts it to ISBN 13.
## Examples
iex> Exisbn.isbn10_to_13("85-359-0277-5")
{:ok, "9788535902778"}
iex> Exisbn.valid?("9788535902778")
true
iex> Exisbn.isbn10_to_13("0306406152")
{:ok, "9780306406157"}
iex> Exisbn.valid?("9780306406157")
true
iex> Exisbn.isbn10_to_13("0-19-853453123")
{:error, :invalid_isbn}
"""
@spec isbn10_to_13(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn10_to_13(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
first_chars = "978#{String.slice(normalize(isbn), 0..8)}"
{:ok, checkdigit} = isbn13_checkdigit(first_chars)
{:ok, "#{first_chars}#{checkdigit}"}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn10_to_13/1`, but raises exception.
## Examples
iex> Exisbn.isbn10_to_13!("85-359-0277-5")
"9788535902778"
iex> Exisbn.valid?("9788535902778")
true
iex> Exisbn.isbn10_to_13!("0306406152")
"9780306406157"
iex> Exisbn.valid?("9780306406157")
true
iex> Exisbn.isbn10_to_13!("0-19-853453123")
** (ArgumentError) Invalid ISBN
"""
@spec isbn10_to_13!(String.t()) :: String.t()
def isbn10_to_13!(isbn) when is_bitstring(isbn) do
case isbn10_to_13(isbn) do
{:ok, result} -> result
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN 13 and converts it to ISBN 10.
ISBNs with prefix `979` have no ISBN-10 equivalent and return
`{:error, :no_isbn10_equivalent}`.
## Examples
iex> Exisbn.isbn13_to_10("9788535902778")
{:ok, "8535902775"}
iex> Exisbn.valid?("8535902775")
true
iex> Exisbn.isbn13_to_10("9780306406157")
{:ok, "0306406152"}
iex> Exisbn.valid?("0306406152")
true
iex> Exisbn.isbn13_to_10("str")
{:error, :invalid_isbn}
iex> Exisbn.isbn13_to_10("9798893031355")
{:error, :no_isbn10_equivalent}
"""
@spec isbn13_to_10(String.t()) ::
{:ok, String.t()} | {:error, :invalid_isbn | :no_isbn10_equivalent}
def isbn13_to_10(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
normalized = normalize(isbn)
if String.starts_with?(normalized, "979") do
{:error, :no_isbn10_equivalent}
else
first_chars = normalized |> drop_chars(3) |> String.slice(0..8)
{:ok, checkdigit} = isbn10_checkdigit(first_chars)
{:ok, "#{first_chars}#{checkdigit}"}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn13_to_10/1`, but raises exception.
## Examples
iex> Exisbn.isbn13_to_10!("9788535902778")
"8535902775"
iex> Exisbn.valid?("8535902775")
true
iex> Exisbn.isbn13_to_10!("9780306406157")
"0306406152"
iex> Exisbn.valid?("0306406152")
true
iex> Exisbn.isbn13_to_10!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.isbn13_to_10!("9798893031355")
** (ArgumentError) No ISBN-10 equivalent
"""
@spec isbn13_to_10!(String.t()) :: String.t()
def isbn13_to_10!(isbn) when is_bitstring(isbn) do
case isbn13_to_10(isbn) do
{:ok, result} -> result
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its publisher zone.
Returns `{:error, :invalid_isbn}` for structurally invalid ISBNs and
`{:error, :unknown_group}` when the registration group is not in the dataset.
## Examples
iex> Exisbn.publisher_zone("9788535902778")
{:ok, "Brazil"}
iex> Exisbn.publisher_zone("2-1234-5680-2")
{:ok, "French language"}
iex> Exisbn.publisher_zone("str")
{:error, :invalid_isbn}
iex> Exisbn.publisher_zone("9799012345674")
{:error, :unknown_group}
"""
@spec publisher_zone(String.t()) ::
{:ok, String.t()} | {:error, :invalid_isbn | :unknown_group}
def publisher_zone(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn = prepare_isbn_13(isbn)
case fetch_info(prepared_isbn) do
nil -> {:error, :unknown_group}
info -> {:ok, Map.get(info, "name")}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `publisher_zone/1`, but raises exception.
## Examples
iex> Exisbn.publisher_zone!("9788535902778")
"Brazil"
iex> Exisbn.publisher_zone!("2-1234-5680-2")
"French language"
iex> Exisbn.publisher_zone!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.publisher_zone!("9799012345674")
** (ArgumentError) Unknown registration group
"""
@spec publisher_zone!(String.t()) :: String.t()
def publisher_zone!(isbn) when is_bitstring(isbn) do
case publisher_zone(isbn) do
{:ok, zone} -> zone
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its ISO 3166-1 alpha-2 country code.
Returns `{:ok, nil}` for groups that span multiple countries or
language areas (e.g. English language, French language, German language,
former U.S.S.R, Caribbean Community).
## Examples
iex> Exisbn.publisher_country_code("9788535902778")
{:ok, "BR"}
iex> Exisbn.publisher_country_code("9780306406157")
{:ok, nil}
iex> Exisbn.publisher_country_code("str")
{:error, :invalid_isbn}
"""
@spec publisher_country_code(String.t()) ::
{:ok, String.t() | nil} | {:error, :invalid_isbn | :unknown_group}
def publisher_country_code(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn = prepare_isbn_13(isbn)
case fetch_info(prepared_isbn) do
nil -> {:error, :unknown_group}
info -> {:ok, Map.get(info, "country_code")}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `publisher_country_code/1`, but raises exception.
## Examples
iex> Exisbn.publisher_country_code!("9788535902778")
"BR"
iex> Exisbn.publisher_country_code!("9780306406157")
nil
iex> Exisbn.publisher_country_code!("str")
** (ArgumentError) Invalid ISBN
"""
@spec publisher_country_code!(String.t()) :: String.t() | nil
def publisher_country_code!(isbn) when is_bitstring(isbn) do
case publisher_country_code(isbn) do
{:ok, code} -> code
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its prefix.
Returns `{:error, :unknown_group}` when the ISBN is structurally valid but belongs
to a registration group not present in the dataset.
## Examples
iex> Exisbn.fetch_prefix("9788535902778")
{:ok, "978-85"}
iex> Exisbn.fetch_prefix("2-1234-5680-2")
{:ok, "978-2"}
iex> Exisbn.fetch_prefix("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_prefix("9799012345674")
{:error, :unknown_group}
"""
@spec fetch_prefix(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn | :unknown_group}
def fetch_prefix(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn = prepare_isbn_13(isbn)
case search_prefix_range(String.slice(prepared_isbn, 0..2), drop_chars(prepared_isbn, 3)) do
nil -> {:error, :unknown_group}
prefix -> {:ok, prefix}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_prefix/1`, but raises exception.
## Examples
iex> Exisbn.fetch_prefix!("9788535902778")
"978-85"
iex> Exisbn.fetch_prefix!("2-1234-5680-2")
"978-2"
iex> Exisbn.fetch_prefix!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.fetch_prefix!("9799012345674")
** (ArgumentError) Unknown registration group
"""
@spec fetch_prefix!(String.t()) :: String.t()
def fetch_prefix!(isbn) when is_bitstring(isbn) do
case fetch_prefix(isbn) do
{:ok, prefix} -> prefix
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its checkdigit.
## Examples
iex> Exisbn.fetch_checkdigit("9788535902778")
{:ok, "8"}
iex> Exisbn.fetch_checkdigit("2-1234-5680-2")
{:ok, "2"}
iex> Exisbn.fetch_checkdigit("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_checkdigit("887385107X")
{:ok, "X"}
"""
@spec fetch_checkdigit(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def fetch_checkdigit(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
{:ok, isbn |> normalize() |> String.last()}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_checkdigit/1`, but raises exception.
## Examples
iex> Exisbn.fetch_checkdigit!("9788535902778")
"8"
iex> Exisbn.fetch_checkdigit!("2-1234-5680-2")
"2"
iex> Exisbn.fetch_checkdigit!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.fetch_checkdigit!("887385107X")
"X"
"""
@spec fetch_checkdigit!(String.t()) :: String.t()
def fetch_checkdigit!(isbn) when is_bitstring(isbn) do
case fetch_checkdigit(isbn) do
{:ok, digit} -> digit
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its registrant element.
Returns `{:error, :unknown_group}` when the registration group is not in the dataset,
and `{:error, :unknown_publisher}` when the group has no publisher ranges defined.
## Examples
iex> Exisbn.fetch_registrant_element("9788535902778")
{:ok, "359"}
iex> Exisbn.fetch_registrant_element("978-1-86197-876-9")
{:ok, "86197"}
iex> Exisbn.fetch_registrant_element("9789529351787")
{:ok, "93"}
iex> Exisbn.fetch_registrant_element("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_registrant_element("9799012345674")
{:error, :unknown_group}
iex> Exisbn.fetch_registrant_element("9786110000000")
{:error, :unknown_publisher}
"""
@spec fetch_registrant_element(String.t()) ::
{:ok, String.t()}
| {:error, :invalid_isbn | :unknown_group | :unknown_publisher}
def fetch_registrant_element(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn = prepare_isbn_13(isbn)
with {:ok, prefix} <- fetch_prefix(prepared_isbn) do
registrant_with_prefix(prepared_isbn, prefix)
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_registrant_element/1`, but raises exception.
## Examples
iex> Exisbn.fetch_registrant_element!("9788535902778")
"359"
iex> Exisbn.fetch_registrant_element!("978-1-86197-876-9")
"86197"
iex> Exisbn.fetch_registrant_element!("9789529351787")
"93"
iex> Exisbn.fetch_registrant_element!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.fetch_registrant_element!("9799012345674")
** (ArgumentError) Unknown registration group
iex> Exisbn.fetch_registrant_element!("9786110000000")
** (ArgumentError) Unknown publisher
"""
@spec fetch_registrant_element!(String.t()) :: String.t()
def fetch_registrant_element!(isbn) when is_bitstring(isbn) do
case fetch_registrant_element(isbn) do
{:ok, result} -> result
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN and returns its publication element.
Propagates `{:error, :unknown_group}` and `{:error, :unknown_publisher}`
from `fetch_registrant_element/1`.
## Examples
iex> Exisbn.fetch_publication_element("978-1-86197-876-9")
{:ok, "876"}
iex> Exisbn.fetch_publication_element("9789529351787")
{:ok, "5178"}
iex> Exisbn.fetch_publication_element("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_publication_element("9799012345674")
{:error, :unknown_group}
iex> Exisbn.fetch_publication_element("9786110000000")
{:error, :unknown_publisher}
"""
@spec fetch_publication_element(String.t()) ::
{:ok, String.t()}
| {:error, :invalid_isbn | :unknown_group | :unknown_publisher}
def fetch_publication_element(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
prepared_isbn = prepare_isbn_13(isbn)
with {:ok, prefix} <- fetch_prefix(prepared_isbn),
{:ok, registrant} <- registrant_with_prefix(prepared_isbn, prefix) do
publication_with_prefix_and_registrant(prepared_isbn, prefix, registrant)
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_publication_element/1`, but raises exception.
## Examples
iex> Exisbn.fetch_publication_element!("978-1-86197-876-9")
"876"
iex> Exisbn.fetch_publication_element!("9789529351787")
"5178"
iex> Exisbn.fetch_publication_element!("str")
** (ArgumentError) Invalid ISBN
iex> Exisbn.fetch_publication_element!("9799012345674")
** (ArgumentError) Unknown registration group
iex> Exisbn.fetch_publication_element!("9786110000000")
** (ArgumentError) Unknown publisher
"""
@spec fetch_publication_element!(String.t()) :: String.t()
def fetch_publication_element!(isbn) when is_bitstring(isbn) do
case fetch_publication_element(isbn) do
{:ok, result} -> result
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Takes an ISBN (10 or 13) and hyphenates it.
## Examples
iex> Exisbn.hyphenate("9788535902778")
{:ok, "978-85-359-0277-8"}
iex> Exisbn.hyphenate("0306406152")
{:ok, "0-306-40615-2"}
iex> Exisbn.hyphenate("str")
{:error, :invalid_isbn}
"""
@spec hyphenate(String.t()) ::
{:ok, String.t()} | {:error, :invalid_isbn | :unknown_group | :unknown_publisher}
def hyphenate(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
if isbn10?(isbn), do: hyphenate_isbn10(isbn), else: hyphenate_isbn13(isbn)
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `hyphenate/1`, but raises exception.
## Examples
iex> Exisbn.hyphenate!("9788535902778")
"978-85-359-0277-8"
iex> Exisbn.hyphenate!("0306406152")
"0-306-40615-2"
iex> Exisbn.hyphenate!("str")
** (ArgumentError) Invalid ISBN
"""
@spec hyphenate!(String.t()) :: String.t()
def hyphenate!(isbn) when is_bitstring(isbn) do
case hyphenate(isbn) do
{:ok, result} -> result
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Checks if an ISBN (10 or 13) code is correctly hyphenated. If ISBN incorrect, that count as no.
## Examples
iex> Exisbn.correct_hyphens?("978-85-359-0277-8")
true
iex> Exisbn.correct_hyphens?("97-8853590277-8")
false
iex> Exisbn.correct_hyphens?("0-306-40615-2")
true
iex> Exisbn.correct_hyphens?("03-064-06152")
false
iex> Exisbn.correct_hyphens?("str")
false
"""
@spec correct_hyphens?(binary) :: boolean
def correct_hyphens?(isbn) when is_bitstring(isbn) do
case hyphenate(isbn) do
{:ok, hyphenated} -> isbn == hyphenated
{:error, _} -> false
end
end
@doc """
Returns all ISBN metadata in a single call.
Fetches the prefix, publisher zone, ISO country code, registrant element,
publication element, and check digit without redundant lookups.
Returns `{:error, :invalid_isbn}` for structurally invalid ISBNs,
`{:error, :unknown_group}` when the registration group is not in the dataset,
and `{:error, :unknown_publisher}` when the group has no publisher ranges defined.
## Examples
iex> Exisbn.fetch_metadata("9788535902778")
{:ok, %{checkdigit: "8", country_code: "BR", prefix: "978-85", publication: "0277", registrant: "359", zone: "Brazil"}}
iex> Exisbn.fetch_metadata("9780306406157")
{:ok, %{checkdigit: "7", country_code: nil, prefix: "978-0", publication: "40615", registrant: "306", zone: "English language"}}
iex> Exisbn.fetch_metadata("str")
{:error, :invalid_isbn}
iex> Exisbn.fetch_metadata("9799012345674")
{:error, :unknown_group}
"""
@spec fetch_metadata(String.t()) ::
{:ok,
%{
prefix: String.t(),
zone: String.t(),
country_code: String.t() | nil,
registrant: String.t(),
publication: String.t(),
checkdigit: String.t()
}}
| {:error, :invalid_isbn | :unknown_group | :unknown_publisher}
def fetch_metadata(isbn) when is_bitstring(isbn) do
if correct?(isbn) do
isbn13 = prepare_isbn_13(isbn)
with {:ok, prefix} <- fetch_prefix(isbn13),
{:ok, registrant} <- registrant_with_prefix(isbn13, prefix),
{:ok, publication} <-
publication_with_prefix_and_registrant(isbn13, prefix, registrant) do
info = Map.get(Regions.dataset(), prefix)
{:ok,
%{
prefix: prefix,
zone: Map.get(info, "name"),
country_code: Map.get(info, "country_code"),
registrant: registrant,
publication: publication,
checkdigit: isbn |> normalize() |> String.last()
}}
end
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `fetch_metadata/1`, but raises exception.
## Examples
iex> meta = Exisbn.fetch_metadata!("9788535902778")
iex> meta.zone
"Brazil"
iex> meta.country_code
"BR"
iex> Exisbn.fetch_metadata!("str")
** (ArgumentError) Invalid ISBN
"""
@spec fetch_metadata!(String.t()) :: map()
def fetch_metadata!(isbn) when is_bitstring(isbn) do
case fetch_metadata(isbn) do
{:ok, metadata} -> metadata
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
defp calculate_isbn10_checkdigit(isbn) do
normalized = normalize(isbn)
if String.length(normalized) in 8..10 do
nsum = isbn10_sum(binary_part(normalized, 0, min(9, byte_size(normalized))), 10, 0)
digit = Integer.mod(11 - Integer.mod(nsum, 11), 11)
{:ok, if(digit == 10, do: "X", else: to_string(digit))}
else
{:error, :invalid_isbn}
end
end
defp isbn10_sum(<<d, rest::binary>>, weight, acc),
do: isbn10_sum(rest, weight - 1, acc + (d - ?0) * weight)
defp isbn10_sum(<<>>, _weight, acc), do: acc
defp calculate_isbn13_checkdigit(isbn) do
normalized = normalize(isbn)
if String.length(normalized) in 11..13 do
nsum = isbn13_sum(binary_part(normalized, 0, min(12, byte_size(normalized))), 0, 0)
digit = 10 - Integer.mod(nsum, 10)
{:ok, to_string(if(digit == 10, do: 0, else: digit))}
else
{:error, :invalid_isbn}
end
end
defp isbn13_sum(<<d, rest::binary>>, index, acc) do
weight = if rem(index, 2) == 0, do: 1, else: 3
isbn13_sum(rest, index + 1, acc + (d - ?0) * weight)
end
defp isbn13_sum(<<>>, _index, acc), do: acc
defp prepare_isbn_13(isbn) do
if isbn10?(isbn) do
case isbn10_to_13(isbn) do
{:ok, converted} -> converted
{:error, _} -> nil
end
else
normalize(isbn)
end
end
@doc """
Returns the type of the ISBN: `:isbn10`, `:isbn13`, or `:invalid`.
Does not require hyphens or any particular formatting — normalization is applied first.
## Examples
iex> Exisbn.isbn_type("978-85-359-0277-8")
:isbn13
iex> Exisbn.isbn_type("85-359-0277-5")
:isbn10
iex> Exisbn.isbn_type("invalid")
:invalid
iex> Exisbn.isbn_type("9788535902778")
:isbn13
"""
@spec isbn_type(String.t()) :: :isbn10 | :isbn13 | :invalid
def isbn_type(isbn) when is_bitstring(isbn) do
normalized = normalize(isbn)
len = String.length(normalized)
cond do
len == 10 and checkdigit_correct_for_normalized?(normalized) -> :isbn10
len == 13 and checkdigit_correct_for_normalized?(normalized) -> :isbn13
true -> :invalid
end
end
def isbn_type(_), do: :invalid
@doc """
Returns the GS1 prefix group (`"978"` or `"979"`) of a valid ISBN-13.
Returns `{:error, :invalid_isbn}` if the input is not a valid ISBN-13.
Accepts hyphenated or plain ISBN-13 strings. ISBN-10 is not accepted —
use `isbn10_to_13/1` first if needed.
## Examples
iex> Exisbn.isbn13_prefix_group("9788535902778")
{:ok, "978"}
iex> Exisbn.isbn13_prefix_group("9798893031355")
{:ok, "979"}
iex> Exisbn.isbn13_prefix_group("978-85-359-0277-8")
{:ok, "978"}
iex> Exisbn.isbn13_prefix_group("85-359-0277-5")
{:error, :invalid_isbn}
iex> Exisbn.isbn13_prefix_group("str")
{:error, :invalid_isbn}
"""
@spec isbn13_prefix_group(String.t()) :: {:ok, String.t()} | {:error, :invalid_isbn}
def isbn13_prefix_group(isbn) when is_bitstring(isbn) do
normalized = normalize(isbn)
if String.length(normalized) == 13 and checkdigit_correct_for_normalized?(normalized) do
{:ok, String.slice(normalized, 0, 3)}
else
{:error, :invalid_isbn}
end
end
@doc """
Same as `isbn13_prefix_group/1`, but raises exception.
## Examples
iex> Exisbn.isbn13_prefix_group!("9788535902778")
"978"
iex> Exisbn.isbn13_prefix_group!("9798893031355")
"979"
iex> Exisbn.isbn13_prefix_group!("str")
** (ArgumentError) Invalid ISBN
"""
@spec isbn13_prefix_group!(String.t()) :: String.t()
def isbn13_prefix_group!(isbn) when is_bitstring(isbn) do
case isbn13_prefix_group(isbn) do
{:ok, group} -> group
{:error, reason} -> raise(ArgumentError, format_error(reason))
end
end
@doc """
Normalizes an ISBN string by removing separators and uppercasing.
Strips hyphens, spaces, and any non-digit characters, then upcases the
result so the check-digit `x` becomes `X`. Returns a bare digit string
(plus optional trailing `X` for ISBN-10).
This function does **not** validate the ISBN — use `valid?/1` for that.
## Examples
iex> Exisbn.normalize("978-85-359-0277-8")
"9788535902778"
iex> Exisbn.normalize("85-359-0277-5")
"8535902775"
iex> Exisbn.normalize("978 85 359 0277 8")
"9788535902778"
iex> Exisbn.normalize("887385107x")
"887385107X"
iex> Exisbn.normalize("9788535902778")
"9788535902778"
"""
@spec normalize(String.t()) :: String.t()
def normalize(isbn) when is_bitstring(isbn) do
isbn
|> String.upcase()
|> String.replace(@non_isbn_chars, "")
end
def normalize(_), do: ""
defp correct_normalized_length?(normalized) do
len = String.length(normalized)
len == 10 or len == 13
end
defp checkdigit_correct_for_normalized?(normalized) do
result =
if String.length(normalized) == 10 do
calculate_isbn10_checkdigit(normalized)
else
calculate_isbn13_checkdigit(normalized)
end
case result do
{:ok, digit} -> digit == String.last(normalized)
{:error, _} -> false
end
end
defp isbn10?(isbn) do
length = isbn |> normalize() |> String.length()
length == 10
end
defp correct?(isbn) do
normalized = normalize(isbn)
correct_normalized_length?(normalized) and checkdigit_correct_for_normalized?(normalized)
end
defp drop_chars(str, amount) do
String.slice(str, amount..String.length(str))
end
defp search_prefix_range(gs1_prefix, body) do
dataset = Regions.dataset()
Enum.find_value(0..5, fn len ->
key = "#{gs1_prefix}-#{String.slice(body, 0, len + 1)}"
if Map.has_key?(dataset, key), do: key
end)
end
defp fetch_body(isbn, prefix) do
isbn
|> drop_chars(String.length(prefix) - 1)
|> String.slice(0..-2//1)
end
defp fetch_info(isbn) do
case fetch_prefix(isbn) do
{:ok, prefix} -> Map.get(Regions.dataset(), prefix)
{:error, _} -> nil
end
end
defp hyphenate_isbn13(isbn) when is_bitstring(isbn) do
isbn13 = normalize(isbn)
with {:ok, prefix} <- fetch_prefix(isbn13),
{:ok, registrant} <- registrant_with_prefix(isbn13, prefix),
{:ok, publication} <-
publication_with_prefix_and_registrant(isbn13, prefix, registrant) do
{:ok, Enum.join([prefix, registrant, publication, String.last(isbn13)], "-")}
end
end
defp hyphenate_isbn10(isbn) when is_bitstring(isbn) do
with {:ok, isbn13} <- isbn10_to_13(isbn),
{:ok, full_prefix} <- fetch_prefix(isbn13),
{:ok, registrant} <- registrant_with_prefix(isbn13, full_prefix),
{:ok, publication} <-
publication_with_prefix_and_registrant(isbn13, full_prefix, registrant) do
isbn10_prefix = String.split(full_prefix, "-", trim: true) |> List.last()
checkdigit = isbn |> normalize() |> String.last()
{:ok, Enum.join([isbn10_prefix, registrant, publication, checkdigit], "-")}
end
end
# Finds the registrant element given a pre-computed prefix and ISBN-13, avoiding
# a redundant fetch_prefix call compared to the public fetch_registrant_element/1.
defp registrant_with_prefix(isbn13, prefix) do
ranges =
case Map.get(Regions.dataset(), prefix) do
nil -> []
info -> Map.get(info, "ranges", [])
end
body = fetch_body(isbn13, prefix)
if Enum.empty?(ranges) do
{:error, :unknown_publisher}
else
Enum.reduce_while(ranges, {:error, :unknown_publisher}, fn {beg, ending, len}, _ ->
range_part = String.slice(body, 0, len)
area = String.to_integer(range_part)
if beg <= area && area <= ending,
do: {:halt, {:ok, range_part}},
else: {:cont, {:error, :unknown_publisher}}
end)
end
end
# Derives the publication element given pre-computed prefix and registrant,
# avoiding redundant lookups compared to the public fetch_publication_element/1.
defp publication_with_prefix_and_registrant(isbn13, prefix, registrant) do
body = fetch_body(isbn13, normalize(prefix))
{:ok, drop_chars(body, String.length(registrant) + 1)}
end
defp format_error(:invalid_isbn), do: "Invalid ISBN"
defp format_error(:unknown_group), do: "Unknown registration group"
defp format_error(:unknown_publisher), do: "Unknown publisher"
defp format_error(:no_isbn10_equivalent), do: "No ISBN-10 equivalent"
end