/*
* Copyright (C) 2014 Jörg Prante
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see http://www.gnu.org/licenses
* or write to the Free Software Foundation, Inc., 51 Franklin Street,
* Fifth Floor, Boston, MA 02110-1301 USA.
*
* The interactive user interfaces in modified source and object code
* versions of this program must display Appropriate Legal Notices,
* as required under Section 5 of the GNU Affero General Public License.
*
*/
package org.xbib.standardnumber;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.events.Characters;
import javax.xml.stream.events.EndElement;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* ISO 2108 International Standard Book Number (ISBN)
*
* Z39.50 BIB-1 Use Attribute 7
*
* The International Standard Book Number is a 13-digit number
* that uniquely identifies books and book-like products published
* internationally.
*
* The purpose of the ISBN is to establish and identify one title or
* edition of a title from one specific publisher
* and is unique to that edition, allowing for more efficient marketing of products by booksellers,
* libraries, universities, wholesalers and distributors.
*
* Every ISBN consists of thirteen digits and whenever it is printed it is preceded by the letters ISBN.
* The thirteen-digit number is divided into four parts of variable length, each part separated by a hyphen.
*
* This class is based upon the ISBN converter and formatter class
* Copyright 2000-2005 by Openly Informatics, Inc. http://www.openly.com/
*
* @see <a href="http://www.s.org/standards/home/s/international/html/usm12.htm">The ISBN Users' Manual</a>
* @see <a href="http://www.ietf.org/html.charters/OLD/urn-charter.html">The IETF URN Charter</a>
* @see <a href="http://www.iana.org/assignments/urn-namespaces">The IANA URN assignments</a>
* @see <a href="http://www.isbn-international.org/download/List%20of%20Ranges.pdf">ISBN prefix list</a>
*/
public class ISBN extends AbstractStandardNumber implements Comparable<ISBN>, StandardNumber {
private static final Pattern PATTERN = Pattern.compile("[\\p{Digit}xX\\-]{10,17}");
private static final List<String> ranges = new ISBNRangeMessageConfigurator().getRanges();
private String value;
private boolean createWithChecksum;
private String eanvalue;
private boolean eanPreferred;
private boolean valid;
private boolean isEAN;
@Override
public String type() {
return "isbn";
}
/**
* Set ISBN value
*
* @param value the ISBN candidate string
*/
@Override
public ISBN set(CharSequence value) {
this.value = value != null ? value.toString() : null;
return this;
}
@Override
public ISBN createChecksum(boolean createWithChecksum) {
this.createWithChecksum = createWithChecksum;
return this;
}
@Override
public int compareTo(ISBN isbn) {
return value != null ? value.compareTo(isbn.normalizedValue()): -1;
}
@Override
public ISBN normalize() {
Matcher m = PATTERN.matcher(value);
this.value = m.find() ? dehyphenate(value.substring(m.start(), m.end())) : null;
return this;
}
/**
* Check for this ISBN number validity
*
* @return true if valid, false otherwise
*/
@Override
public boolean isValid() throws NumberFormatException {
return value != null && !value.isEmpty() && check() && (eanPreferred ? eanvalue != null : value != null);
}
@Override
public ISBN verify() throws NumberFormatException {
if (value == null || value.isEmpty()) {
throw new NumberFormatException("must not be null");
}
check();
this.valid = eanPreferred ? eanvalue != null : value != null;
if (!valid) {
throw new NumberFormatException("invalid number");
}
return this;
}
/**
* Get the normalized value of this standard book number
*
* @return the value of this standard book number
*/
@Override
public String normalizedValue() {
return eanPreferred ? eanvalue : value;
}
/**
* Get printable representation of this standard book number
*
* @return ISBN-13, with (fixed) check digit
*/
@Override
public String format() {
if ((!eanPreferred && value == null) || eanvalue == null) {
return null;
}
return eanPreferred ?
fix(eanvalue) :
fix("978" + value).substring(4);
}
@Override
public ISBN reset() {
this.value = null;
this.createWithChecksum = false;
this.eanvalue = null;
this.eanPreferred = false;
this.valid = false;
this.isEAN = false;
return this;
}
public boolean isEAN() {
return isEAN;
}
/**
* Prefer European Article Number (EAN, ISBN-13)
*/
public ISBN ean(boolean preferEAN) {
this.eanPreferred = preferEAN;
return this;
}
/**
* Get country and publisher code
*
* @return the country/publisher code from ISBN
*/
public String getCountryAndPublisherCode() {
// we don't care about the wrong createChecksum when we fix the value
String code = eanvalue != null ? fix(eanvalue) : fix("978" + value);
String s = code.substring(4);
int pos1 = s.indexOf('-');
if (pos1 <= 0) {
return null;
}
String pubCode = s.substring(pos1 + 1);
int pos2 = pubCode.indexOf('-');
if (pos2 <= 0) {
return null;
}
return code.substring(0, pos1 + pos2 + 5);
}
private String hyphenate(String prefix, String isbn) {
StringBuilder sb = new StringBuilder(prefix.substring(0, 4)); // '978-', '979-'
prefix = prefix.substring(4);
isbn = isbn.substring(3); // 978, 979
int i = 0;
int j = 0;
while (i < prefix.length()) {
char ch = prefix.charAt(i++);
if (ch == '-') {
sb.append('-'); // set first hyphen
} else {
sb.append(isbn.charAt(j++));
}
}
sb.append('-'); // set second hyphen
while (j < (isbn.length() - 1)) {
sb.append(isbn.charAt(j++));
}
sb.append('-'); // set third hyphen
sb.append(isbn.charAt(isbn.length() - 1));
return sb.toString();
}
private boolean check() {
this.eanvalue = null;
this.isEAN = false;
int i;
int val;
if (value.length() < 9) {
return false;
}
if (value.length() == 10) {
// ISBN-10
int checksum = 0;
int weight = 10;
for (i = 0; weight > 0; i++) {
val = value.charAt(i) == 'X' || value.charAt(i) == 'x' ? 10
: value.charAt(i) - '0';
if (val >= 0) {
if (val == 10 && weight != 1) {
return false;
}
checksum += weight * val;
weight--;
} else {
return false;
}
}
String s = value.substring(0, 9);
if (checksum % 11 != 0) {
if (createWithChecksum) {
this.value = s + createCheckDigit10(s);
} else {
return false;
}
}
this.eanvalue = "978" + s + createCheckDigit13("978" + s);
} else if (value.length() == 13) {
// ISBN-13 "book land"
if (!value.startsWith("978") && !value.startsWith("979")) {
return false;
}
int checksum13 = 0;
int weight13 = 1;
for (i = 0; i < 13; i++) {
val = value.charAt(i) == 'X' || value.charAt(i) == 'x' ? 10 : value.charAt(i) - '0';
if (val >= 0) {
if (val == 10) {
return false;
}
checksum13 += (weight13 * val);
weight13 = (weight13 + 2) % 4;
} else {
return false;
}
}
// set value
if ((checksum13 % 10) != 0) {
if (eanPreferred && createWithChecksum) {
// with createChecksum
eanvalue = value.substring(0, 12) + createCheckDigit13(value.substring(0, 12));
} else {
return false;
}
} else {
eanvalue = value;
}
if (!eanPreferred && (eanvalue.startsWith("978") || eanvalue.startsWith("979"))) {
// create 10-digit from 13-digit
this.value = eanvalue.substring(3, 12) + createCheckDigit10(eanvalue.substring(3, 12));
} else {
// 10 digit version not available - not an error
this.value = null;
}
this.isEAN = true;
} else if (value.length() == 9) {
String s = value.substring(0, 9);
// repair ISBN-10 ?
if (createWithChecksum) {
// create 978 from 10-digit without createChecksum
eanvalue = "978" + s + createCheckDigit13("978" + s);
value = s + createCheckDigit10(s);
} else {
return false;
}
} else if (value.length() == 12) {
// repair ISBN-13 ?
if (!value.startsWith("978") && !value.startsWith("979")) {
return false;
}
if (createWithChecksum) {
String s = value.substring(0, 9);
String t = value.substring(3, 12);
// create 978 from 10-digit
this.eanvalue = "978" + s + createCheckDigit13("978" + s);
this.value = t + createCheckDigit10(t);
} else {
return false;
}
this.isEAN = true;
} else {
return false;
}
return true;
}
/**
* Returns a ISBN check digit for the first 9 digits in a string
*
* @param value the value
* @return check digit
*
* @throws NumberFormatException
*/
private char createCheckDigit10(String value) throws NumberFormatException {
int checksum = 0;
int val;
int l = value.length();
for (int i = 0; i < l; i++) {
val = value.charAt(i) - '0';
if (val < 0 || val > 9) {
throw new NumberFormatException("not a digit in " + value);
}
checksum += val * (10-i);
}
int mod = checksum % 11;
return mod == 0 ? '0' : mod == 1 ? 'X' : (char)((11-mod) + '0');
}
/**
* Returns an ISBN check digit for the first 12 digits in a string
*
* @param value the value
* @return check digit
*
* @throws NumberFormatException
*/
private char createCheckDigit13(String value) throws NumberFormatException {
int checksum = 0;
int weight;
int val;
int l = value.length();
for (int i = 0; i < l; i++) {
val = value.charAt(i) - '0';
if (val < 0 || val > 9) {
throw new NumberFormatException("not a digit in " + value);
}
weight = i % 2 == 0 ? 1 : 3;
checksum += weight * val;
}
int mod = 10 - checksum % 10;
return mod == 10 ? '0' : (char)(mod + '0');
}
private String fix(String isbn) {
if (isbn == null) {
return null;
}
for (int i = 0; i < ranges.size(); i += 2) {
if (isInRange(isbn, ranges.get(i), ranges.get(i + 1)) == 0) {
return hyphenate(ranges.get(i), isbn);
}
}
return isbn;
}
/**
* Check if ISBN is within a given value range
* @param isbn ISBN to check
* @param begin lower ISBN
* @param end higher ISBN
* @return -1 if too low, 1 if too high, 0 if range matches
*/
private int isInRange(String isbn, String begin, String end) {
String b = dehyphenate(begin);
int blen = b.length();
int c = blen <= isbn.length() ?
isbn.substring(0, blen).compareTo(b) :
isbn.compareTo(b);
if (c < 0) {
return -1;
}
String e = dehyphenate(end);
int elen = e.length();
c = e.compareTo(isbn.substring(0, elen));
if (c < 0) {
return 1;
}
return 0;
}
private String dehyphenate(String isbn) {
StringBuilder sb = new StringBuilder(isbn);
int i = sb.indexOf("-");
while (i >= 0) {
sb.deleteCharAt(i);
i = sb.indexOf("-");
}
return sb.toString();
}
private final static class ISBNRangeMessageConfigurator {
private final Stack<StringBuilder> content;
private final List<String> ranges;
private String prefix;
private String rangeBegin;
private String rangeEnd;
private int length;
private boolean valid;
public ISBNRangeMessageConfigurator() {
content = new Stack<StringBuilder>();
ranges = new ArrayList<String>();
length = 0;
try {
InputStream in = getClass().getResourceAsStream("/standardnumber/RangeMessage.xml");
XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance();
XMLEventReader xmlReader = xmlInputFactory.createXMLEventReader(in);
while (xmlReader.hasNext()) {
processEvent(xmlReader.peek());
xmlReader.nextEvent();
}
} catch (XMLStreamException e) {
throw new RuntimeException(e.getMessage());
}
}
private void processEvent(XMLEvent e) {
switch (e.getEventType()) {
case XMLEvent.START_ELEMENT: {
StartElement element = e.asStartElement();
String name = element.getName().getLocalPart();
if ("RegistrationGroups".equals(name)) {
valid = true;
}
content.push(new StringBuilder());
break;
}
case XMLEvent.END_ELEMENT: {
EndElement element = e.asEndElement();
String name = element.getName().getLocalPart();
String v = content.pop().toString();
if ("Prefix".equals(name)) {
prefix = v;
}
if ("Range".equals(name)) {
int pos = v.indexOf('-');
if (pos > 0) {
rangeBegin = v.substring(0, pos);
rangeEnd = v.substring(pos + 1);
}
}
if ("Length".equals(name)) {
length = Integer.parseInt(v);
}
if ("Rule".equals(name)) {
if (valid && rangeBegin != null && rangeEnd != null) {
if (length > 0) {
ranges.add(prefix + "-" + rangeBegin.substring(0, length));
ranges.add(prefix + "-" + rangeEnd.substring(0, length));
}
}
}
break;
}
case XMLEvent.CHARACTERS: {
Characters c = (Characters) e;
if (!c.isIgnorableWhiteSpace()) {
String text = c.getData().trim();
if (text.length() > 0 && !content.empty()) {
content.peek().append(text);
}
}
break;
}
}
}
public List<String> getRanges() {
return ranges;
}
}
}