001
002package org.xbib.standardnumber;
003
004import javax.xml.stream.XMLEventReader;
005import javax.xml.stream.XMLInputFactory;
006import javax.xml.stream.XMLStreamException;
007import javax.xml.stream.events.Characters;
008import javax.xml.stream.events.EndElement;
009import javax.xml.stream.events.StartElement;
010import javax.xml.stream.events.XMLEvent;
011import java.io.InputStream;
012import java.util.ArrayList;
013import java.util.List;
014import java.util.Stack;
015import java.util.regex.Matcher;
016import java.util.regex.Pattern;
017
018/**
019 * ISO 2108 International Standard Book Number (ISBN)
020 *
021 * Z39.50 BIB-1 Use Attribute 7
022 *
023 * The International Standard Book Number is a 13-digit number
024 * that uniquely identifies books and book-like products published
025 * internationally.
026 *
027 * The purpose of the ISBN is to establish and identify one title or
028 * edition of a title from one specific publisher
029 * and is unique to that edition, allowing for more efficient marketing of products by booksellers,
030 * libraries, universities, wholesalers and distributors.
031 *
032 * Every ISBN consists of thirteen digits and whenever it is printed it is preceded by the letters ISBN.
033 * The thirteen-digit number is divided into four parts of variable length, each part separated by a hyphen.
034 *
035 * This class is based upon the ISBN converter and formatter class
036 * Copyright 2000-2005 by Openly Informatics, Inc. http://www.openly.com/
037 *
038 * @see <a href="http://www.s.org/standards/home/s/international/html/usm12.htm">The ISBN Users' Manual</a>
039 * @see <a href="http://www.ietf.org/html.charters/OLD/urn-charter.html">The IETF URN Charter</a>
040 * @see <a href="http://www.iana.org/assignments/urn-namespaces">The IANA URN assignments</a>
041 * @see <a href="http://www.isbn-international.org/download/List%20of%20Ranges.pdf">ISBN prefix list</a>
042 */
043public class ISBN extends AbstractStandardNumber implements Comparable<ISBN>, StandardNumber {
044
045    private static final Pattern PATTERN = Pattern.compile("[\\p{Digit}xX\\-]{10,17}");
046
047    private static final List<String> ranges = new ISBNRangeMessageConfigurator().getRanges();
048
049    private String value;
050
051    private boolean createWithChecksum;
052
053    private String eanvalue;
054
055    private boolean eanPreferred;
056
057    private boolean valid;
058
059    private boolean isEAN;
060
061    @Override
062    public String type() {
063        return "isbn";
064    }
065
066    /**
067     * Set ISBN value
068     *
069     * @param value the ISBN candidate string
070     */
071    @Override
072    public ISBN set(CharSequence value) {
073        this.value = value != null ? value.toString() : null;
074        return this;
075    }
076
077    @Override
078    public ISBN createChecksum(boolean createWithChecksum) {
079        this.createWithChecksum = createWithChecksum;
080        return this;
081    }
082
083    @Override
084    public int compareTo(ISBN isbn) {
085        return value != null ? value.compareTo(isbn.normalizedValue()): -1;
086    }
087
088    @Override
089    public ISBN normalize() {
090        Matcher m = PATTERN.matcher(value);
091        this.value = m.find() ? dehyphenate(value.substring(m.start(), m.end())) : null;
092        return this;
093    }
094
095    /**
096     * Check for this ISBN number validity
097     *
098     * @return true if valid, false otherwise
099     */
100    @Override
101    public boolean isValid() throws NumberFormatException {
102        return value != null && !value.isEmpty() && check() && (eanPreferred ? eanvalue != null : value != null);
103    }
104
105    @Override
106    public ISBN verify() throws NumberFormatException {
107        if (value == null || value.isEmpty()) {
108            throw new NumberFormatException("must not be null");
109        }
110        check();
111        this.valid = eanPreferred ? eanvalue != null : value != null;
112        if (!valid) {
113            throw new NumberFormatException("invalid number");
114        }
115        return this;
116    }
117
118    /**
119     * Get the normalized value of this standard book number
120     * 
121     * @return the value of this standard book number
122     */
123    @Override
124    public String normalizedValue() {
125        return eanPreferred ? eanvalue : value;
126    }
127
128    /**
129     * Get printable representation of this standard book number
130     *
131     * @return ISBN-13, with (fixed) check digit
132     */
133    @Override
134    public String format() {
135        if ((!eanPreferred && value == null) || eanvalue == null) {
136            return null;
137        }
138        return eanPreferred ?
139                        fix(eanvalue != null ? eanvalue : "978" + value) :
140                        fix("978" + value).substring(4);
141    }
142
143    @Override
144    public ISBN reset() {
145        this.value = null;
146        this.createWithChecksum = false;
147        this.eanvalue = null;
148        this.eanPreferred = false;
149        this.valid = false;
150        this.isEAN = false;
151        return this;
152    }
153
154    public boolean isEAN() {
155        return isEAN;
156    }
157
158    /**
159     * Prefer European Article Number (EAN, ISBN-13)
160     */
161    public ISBN ean(boolean preferEAN) {
162        this.eanPreferred = preferEAN;
163        return this;
164    }
165
166    /**
167     * Get country and publisher code
168     *
169     * @return the country/publisher code from ISBN
170     */
171    public String getCountryAndPublisherCode()  {
172        // we don't care about the wrong createChecksum when we fix the value
173        String code = eanvalue != null ? fix(eanvalue) : fix("978" + value);
174        String s = code.substring(4);
175        int pos1 = s.indexOf('-');
176        if (pos1 <= 0) {
177            return null;
178        }
179        String pubCode = s.substring(pos1 + 1);
180        int pos2 = pubCode.indexOf('-');
181        if (pos2 <= 0) {
182            return null;
183        }
184        return code.substring(0, pos1 + pos2 + 5);
185    }
186
187    private String hyphenate(String prefix, String isbn) {
188        StringBuilder sb = new StringBuilder(prefix.substring(0, 4)); // '978-', '979-'
189        prefix = prefix.substring(4);
190        isbn = isbn.substring(3); // 978, 979
191        int i = 0;
192        int j = 0;
193        while (i < prefix.length()) {
194            char ch = prefix.charAt(i++);
195            if (ch == '-') {
196                sb.append('-'); // set first hyphen
197            } else {
198                sb.append(isbn.charAt(j++));
199            }
200        }
201        sb.append('-'); // set second hyphen
202        while (j < (isbn.length() - 1)) {
203            sb.append(isbn.charAt(j++));
204        }
205        sb.append('-'); // set third hyphen
206        sb.append(isbn.charAt(isbn.length() - 1));
207        return sb.toString();
208    }
209
210    private boolean check() {
211        this.eanvalue = null;
212        this.isEAN = false;
213        int i;
214        int val;
215        if (value.length() < 9) {
216            return false;
217        }
218        if (value.length() == 10) {
219            // ISBN-10
220            int checksum = 0;
221            int weight = 10;
222            for (i = 0; weight > 0; i++) {
223                val = value.charAt(i) == 'X' || value.charAt(i) == 'x' ? 10
224                        : value.charAt(i) - '0';
225                if (val >= 0) {
226                    if (val == 10 && weight != 1) {
227                        return false;
228                    }
229                    checksum += weight * val;
230                    weight--;
231                } else {
232                    return false;
233                }
234            }
235            String s = value.substring(0, 9);
236            if (checksum % 11 != 0) {
237                if (createWithChecksum) {
238                    this.value = s + createCheckDigit10(s);
239                } else {
240                    return false;
241                }
242            }
243            this.eanvalue = "978" + s + createCheckDigit13("978" + s);
244        } else if (value.length() == 13) {
245            // ISBN-13 "book land"
246            if (!value.startsWith("978") && !value.startsWith("979")) {
247                return false;
248            }
249            int checksum13 = 0;
250            int weight13 = 1;
251            for (i = 0; i < 13; i++) {
252                val = value.charAt(i) == 'X' || value.charAt(i) == 'x' ? 10 : value.charAt(i) - '0';
253                if (val >= 0) {
254                    if (val == 10) {
255                        return false;
256                    }
257                    checksum13 += (weight13 * val);
258                    weight13 = (weight13 + 2) % 4;
259                } else {
260                    return false;
261                }
262            }
263            // set value
264            if ((checksum13 % 10) != 0) {
265                if (eanPreferred && createWithChecksum) {
266                    // with createChecksum
267                    eanvalue = value.substring(0, 12) + createCheckDigit13(value.substring(0, 12));
268                } else {
269                    return false;
270                }
271            } else {
272                eanvalue = value;
273            }
274            if (!eanPreferred && (eanvalue.startsWith("978") || eanvalue.startsWith("979"))) {
275                // create 10-digit from 13-digit
276                this.value = eanvalue.substring(3, 12) + createCheckDigit10(eanvalue.substring(3, 12));
277            } else {
278                // 10 digit version not available - not an error
279                this.value = null;
280            }
281            this.isEAN = true;
282        } else if (value.length() == 9) {
283            String s = value.substring(0, 9);
284            // repair ISBN-10 ?
285            if (createWithChecksum) {
286                // create 978 from 10-digit without createChecksum
287                eanvalue = "978" + s + createCheckDigit13("978" + s);
288                value = s + createCheckDigit10(s);
289            } else {
290                return false;
291            }
292        } else if (value.length() == 12) {
293            // repair ISBN-13 ?
294            if (!value.startsWith("978") && !value.startsWith("979")) {
295                return false;
296            }
297            if (createWithChecksum) {
298                String s = value.substring(0, 9);
299                String t = value.substring(3, 12);
300                // create 978 from 10-digit
301                this.eanvalue = "978" + s + createCheckDigit13("978" + s);
302                this.value = t + createCheckDigit10(t);
303            } else {
304                return false;
305            }
306            this.isEAN = true;
307        } else {
308            return false;
309        }
310        return true;
311    }
312
313    /**
314     * Returns a ISBN check digit for the first 9 digits in a string
315     *
316     * @param value the value
317     * @return check digit
318     *
319     * @throws NumberFormatException
320     */
321    private char createCheckDigit10(String value) throws NumberFormatException {
322        int checksum = 0;
323        int val;
324        int l = value.length();
325        for (int i = 0; i < l; i++) {
326            val = value.charAt(i) - '0';
327            if (val < 0 || val > 9) {
328                throw new NumberFormatException("not a digit in " + value);
329            }
330            checksum += val * (10-i);
331        }
332        int mod = checksum % 11;
333        return mod == 0 ? '0' : mod == 1 ? 'X' : (char)((11-mod) + '0');
334    }
335
336    /**
337     * Returns an ISBN check digit for the first 12 digits in a string
338     *
339     * @param value the value
340     * @return check digit
341     *
342     * @throws NumberFormatException
343     */
344    private char createCheckDigit13(String value) throws NumberFormatException {
345        int checksum = 0;
346        int weight;
347        int val;
348        int l = value.length();
349        for (int i = 0; i < l; i++) {
350            val = value.charAt(i) - '0';
351            if (val < 0 || val > 9) {
352                throw new NumberFormatException("not a digit in " + value);
353            }
354            weight = i % 2 == 0 ? 1 : 3;
355            checksum += weight * val;
356        }
357        int mod = 10 - checksum % 10;
358        return mod == 10 ? '0' : (char)(mod + '0');
359    }
360
361    private String fix(String isbn) {
362        if (isbn == null) {
363            return null;
364        }
365        for (int i = 0; i < ranges.size(); i += 2) {
366            if (isInRange(isbn, ranges.get(i), ranges.get(i + 1)) == 0) {
367                return hyphenate(ranges.get(i), isbn);
368            }
369        }
370        return isbn;
371    }
372
373    /**
374     * Check if ISBN is within a given value range
375     * @param isbn ISBN to check
376     * @param begin lower ISBN
377     * @param end  higher ISBN
378     * @return -1 if too low, 1 if too high, 0 if range matches
379     */
380    private int isInRange(String isbn, String begin, String end) {
381        String b = dehyphenate(begin);
382        int blen = b.length();
383        int c = blen <= isbn.length() ?
384                isbn.substring(0, blen).compareTo(b) :
385                isbn.compareTo(b);
386        if (c < 0) {
387            return -1;
388        }
389        String e = dehyphenate(end);
390        int elen = e.length();
391        c = e.compareTo(isbn.substring(0, elen));
392        if (c < 0) {
393            return 1;
394        }
395        return 0;
396    }
397
398    private String dehyphenate(String isbn) {
399        StringBuilder sb = new StringBuilder(isbn);
400        int i = sb.indexOf("-");
401        while (i >= 0) {
402            sb.deleteCharAt(i);
403            i = sb.indexOf("-");
404        }
405        return sb.toString();
406    }
407
408    private final static class ISBNRangeMessageConfigurator {
409
410        private final Stack<StringBuilder> content;
411
412        private final List<String> ranges;
413
414        private String prefix;
415
416        private String rangeBegin;
417
418        private String rangeEnd;
419
420        private int length;
421
422        private boolean valid;
423
424        public ISBNRangeMessageConfigurator() {
425            content = new Stack<StringBuilder>();
426            ranges = new ArrayList<String>();
427            length = 0;
428            try {
429                InputStream in = getClass().getResourceAsStream("/org/xbib/standardnumber/RangeMessage.xml");
430                XMLInputFactory xmlInputFactory = XMLInputFactory.newInstance();
431                XMLEventReader xmlReader = xmlInputFactory.createXMLEventReader(in);
432                while (xmlReader.hasNext()) {
433                    processEvent(xmlReader.peek());
434                    xmlReader.nextEvent();
435                }
436            } catch (XMLStreamException e) {
437                throw new RuntimeException(e.getMessage());
438            }
439        }
440
441        private void processEvent(XMLEvent e) {
442            switch (e.getEventType()) {
443                case XMLEvent.START_ELEMENT: {
444                    StartElement element = e.asStartElement();
445                    String name = element.getName().getLocalPart();
446                    if ("RegistrationGroups".equals(name)) {
447                        valid = true;
448                    }
449                    content.push(new StringBuilder());
450                    break;
451                }
452                case XMLEvent.END_ELEMENT: {
453                    EndElement element = e.asEndElement();
454                    String name = element.getName().getLocalPart();
455                    String v = content.pop().toString();
456                    if ("Prefix".equals(name)) {
457                        prefix = v;
458                    }
459                    if ("Range".equals(name)) {
460                        int pos = v.indexOf('-');
461                        if (pos > 0) {
462                            rangeBegin = v.substring(0, pos);
463                            rangeEnd = v.substring(pos + 1);
464                        }
465                    }
466                    if ("Length".equals(name)) {
467                        length = Integer.parseInt(v);
468                    }
469                    if ("Rule".equals(name)) {
470                        if (valid && rangeBegin != null && rangeEnd != null) {
471                            if (length > 0) {
472                                ranges.add(prefix + "-" + rangeBegin.substring(0, length));
473                                ranges.add(prefix + "-" + rangeEnd.substring(0, length));
474                            }
475                        }
476                    }
477                    break;
478                }
479                case XMLEvent.CHARACTERS: {
480                    Characters c = (Characters) e;
481                    if (!c.isIgnorableWhiteSpace()) {
482                        String text = c.getData().trim();
483                        if (text.length() > 0 && !content.empty()) {
484                            content.peek().append(text);
485                        }
486                    }
487                    break;
488                }
489            }
490        }
491
492        public List<String> getRanges() {
493            return ranges;
494        }
495    }
496
497}
498
499