from typing import List, Optional from .validation_isbn_types import IsbnCheck def _invalid(reason: str) -> IsbnCheck: return IsbnCheck(valid=False, normalised=None, isbn10=None, reason=reason) def _ean_check(twelve: str) -> str: """The EAN-13 check digit for twelve digits: weights 1, 3, 1, 3 ... from the left.""" total = sum((ord(twelve[i]) - 48) * (1 if i % 2 == 0 else 3) for i in range(12)) return str((10 - total % 10) % 10) def _isbn10_check(nine: str) -> str: """The ISBN-10 check character for nine digits: weights 10 down to 2, mod 11, 10 written X.""" total = sum((ord(nine[i]) - 48) * (10 - i) for i in range(9)) check = (11 - total % 11) % 11 return "X" if check == 10 else str(check) def validate_isbn(value: str) -> IsbnCheck: """Check an ISBN-10 or ISBN-13 and return it as an ISBN-13, with its ISBN-10 where one exists. The two forms use different check digits, so conversion recomputes rather than copies. Validators answer rather than raise. """ if not isinstance(value, str): return _invalid("empty") # Hyphen positions vary by publisher, so separators are ignored wholesale. compact = "".join(ch for ch in value if ch != " " and ch != "-") # An optional "ISBN" label, any case, then an optional colon. Folded by # hand: str.upper() is Unicode-aware and the Rust sibling is not. if len(compact) >= 4: label = "".join(chr(ord(c) - 32) if "a" <= c <= "z" else c for c in compact[:4]) if label == "ISBN": compact = compact[4:] if compact.startswith(":"): compact = compact[1:] if not compact: return _invalid("empty") out: List[str] = [] for ch in compact: if "0" <= ch <= "9": out.append(ch) elif ch == "X" or ch == "x": out.append("X") else: return _invalid("bad-character") isbn = "".join(out) if len(isbn) != 10 and len(isbn) != 13: return _invalid("bad-length") # X means ten, so it can only be an ISBN-10's check digit. x_at = isbn.find("X") if x_at != -1 and (len(isbn) == 13 or x_at != 9): return _invalid("bad-format") if len(isbn) == 13: prefix = isbn[:3] if prefix != "978" and prefix != "979": return _invalid("bad-prefix") if _ean_check(isbn) != isbn[12]: return _invalid("bad-check-digit") # 979 ISBNs were never ISBN-10s, so they have no ISBN-10 to give back. isbn10: Optional[str] = isbn[3:12] + _isbn10_check(isbn[3:12]) if prefix == "978" else None return IsbnCheck(valid=True, normalised=isbn, isbn10=isbn10, reason=None) if _isbn10_check(isbn) != isbn[9]: return _invalid("bad-check-digit") body = "978" + isbn[:9] return IsbnCheck(valid=True, normalised=body + _ean_check(body), isbn10=isbn, reason=None)