package nl.siegmann.epublib.epub;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.xml.parsers.ParserConfigurationException;
import nl.siegmann.epublib.Constants;
import nl.siegmann.epublib.domain.Book;
import nl.siegmann.epublib.domain.Guide;
import nl.siegmann.epublib.domain.GuideReference;
import nl.siegmann.epublib.domain.MediaType;
import nl.siegmann.epublib.domain.Resource;
import nl.siegmann.epublib.domain.Resources;
import nl.siegmann.epublib.domain.Spine;
import nl.siegmann.epublib.domain.SpineReference;
import nl.siegmann.epublib.service.MediatypeService;
import nl.siegmann.epublib.util.ResourceUtil;
import org.apache.commons.io.Charsets;
import org.rr.commons.log.LoggerFactory;
import org.rr.commons.utils.StringUtil;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Reads the opf package document as defined by namespace http://www.idpf.org/2007/opf
*
* @author paul
*
*/
public class PackageDocumentReader extends PackageDocumentBase {
private static final Logger log = java.util.logging.Logger.getLogger(PackageDocumentReader.class.getName());
private static final String[] POSSIBLE_NCX_ITEM_IDS = new String[] {"toc", "ncx"};
public static void read(Resource packageResource, EpubReader epubReader, Book book, Resources resources) throws UnsupportedEncodingException, SAXException, IOException, ParserConfigurationException {
Document packageDocument = ResourceUtil.getAsDocument(packageResource);
String packageHref = packageResource.getHref();
resources = setPackageResources(resources, packageHref);
// resources = fixHrefs(packageHref, resources);
readGuide(packageDocument, epubReader, book, resources);
// Books sometimes use non-identifier ids. We map these here to legal ones
Map<String, String> idMapping = new HashMap<String, String>();
resources = readManifest(book, packageDocument, packageHref, epubReader, resources, idMapping);
book.setResources(resources);
readCover(packageDocument, book);
book.setMetadata(PackageDocumentMetadataReader.readMetadata(packageDocument, book.getResources()));
book.setSpine(readSpine(packageDocument, epubReader, book.getResources(), idMapping));
// if we did not find a cover page then we make the first page of the book the cover page
if (book.getCoverPage() == null && book.getSpine().size() > 0) {
book.setCoverPage(book.getSpine().getResource(0));
}
}
private static Resources setPackageResources(Resources resources, String packageHref) {
Resources result = new Resources();
Collection<Resource> all = resources.getAll();
for(Resource resource : all) {
resource.setPackageHref(packageHref);
result.add(resource);
}
return result;
}
/**
* Reads the manifest containing the resource ids, hrefs and mediatypes.
*
* @param packageDocument
* @param packageHref
* @param epubReader
* @param book
* @param resourcesByHref
* @return a Map with resources, with their id's as key.
*/
private static Resources readManifest(Book book, Document packageDocument, String packageHref,
EpubReader epubReader, Resources resources, Map<String, String> idMapping) {
Element manifestElement = DOMUtil.getFirstElementByTagNameNS(packageDocument.getDocumentElement(), NAMESPACE_OPF, OPFTags.manifest);
Resources result = new Resources();
if(manifestElement == null) {
log.warning("Package document does not contain element " + OPFTags.manifest);
return result;
}
NodeList itemElements = manifestElement.getElementsByTagNameNS(NAMESPACE_OPF, OPFTags.item);
for(int i = 0; i < itemElements.getLength(); i++) {
Element itemElement = (Element) itemElements.item(i);
String id = DOMUtil.getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.id);
String href = DOMUtil.getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.href);
try {
href = URLDecoder.decode(href, Constants.ENCODING);
} catch (UnsupportedEncodingException e) {
log.warning(e.getMessage());
}
String mediaTypeName = DOMUtil.getAttribute(itemElement, NAMESPACE_OPF, OPFAttributes.media_type);
Resource resource = resources.remove(href);
if(resource == null) {
//Possibly any charset could be used to store file names in zip files. Try all available ones
//to find the referring resource.
resource = findHrefResource(href, resources);
if(resource != null) {
resources.remove(resource.getHref());
resource.setHref(href);
}
if(resource == null) {
log.warning("resource with href '" + href + "' not found in " + book.getName());
continue;
}
}
resource.setId(id);
MediaType mediaType = MediatypeService.getMediaTypeByName(mediaTypeName);
if(mediaType != null) {
resource.setMediaType(mediaType);
}
result.add(resource);
idMapping.put(id, resource.getId());
}
return result;
}
private static Resource findHrefResource(String href, final Resources resources) {
//Possibly any charset could be used to store file names in zip files.
try {
final boolean urlEncoded = href.indexOf('%') != -1;
final SortedMap<String, Charset> availableCharsets = Charset.availableCharsets();
final String compareHref = urlEncoded ? URLDecoder.decode(href, Charsets.UTF_8.name()) : href;
final Collection<Resource> allResources = resources.getAll();
//try charset encodings for wrong encoded content.opf
for(Charset c : availableCharsets.values()) {
String newEncodedHref = new String(href.getBytes(), c);
if(resources.containsByHref(newEncodedHref)) {
return resources.getByHref(newEncodedHref);
} else if(urlEncoded) {
try {
String urlDecodedHref = URLDecoder.decode(href, c.name());
if(resources.containsByHref(urlDecodedHref)) {
return resources.getByHref(urlDecodedHref);
}
} catch (UnsupportedEncodingException e) {
}
}
}
//try charset encodings for zip file entries.
for(Resource resource : allResources) {
String oldHref = resource.getHref();
for(Charset c : availableCharsets.values()) {
byte[] rawHref = resource.getRawHref();
if(rawHref != null) {
String newEncodedResourceHref = new String(rawHref, c);
resource.setHref(newEncodedResourceHref);
if(resource.getHref().equals(compareHref)) {
return resource;
}
}
}
resource.setHref(oldHref);
}
} catch (UnsupportedEncodingException e) {
LoggerFactory.getLogger().log(Level.WARNING, "Failed to encode raw for " + href, e);
}
return null;
}
/**
* Reads the book's guide.
* Here some more attempts are made at finding the cover page.
*
* @param packageDocument
* @param epubReader
* @param book
* @param resources
*/
private static void readGuide(Document packageDocument,
EpubReader epubReader, Book book, Resources resources) {
Element guideElement = DOMUtil.getFirstElementByTagNameNS(packageDocument.getDocumentElement(), NAMESPACE_OPF, OPFTags.guide);
if(guideElement == null) {
return;
}
Guide guide = book.getGuide();
NodeList guideReferences = guideElement.getElementsByTagNameNS(NAMESPACE_OPF, OPFTags.reference);
for (int i = 0; i < guideReferences.getLength(); i++) {
Element referenceElement = (Element) guideReferences.item(i);
String resourceHref = DOMUtil.getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.href);
if (StringUtil.isEmpty(resourceHref)) {
continue;
}
String guideHref = StringUtil.substringBefore(resourceHref, Constants.FRAGMENT_SEPARATOR_CHAR);
Resource resource = resources.getByHref(guideHref);
if (resource == null) {
resource = findHrefResource(guideHref, resources);
if(resource != null) {
resource.setHref(guideHref);
} else {
log.warning("Guide is referencing resource with href " + resourceHref + " which could not be found");
continue;
}
}
String type = DOMUtil.getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.type);
if (StringUtil.isEmpty(type)) {
log.warning("Guide is referencing resource with href " + resourceHref + " which is missing the 'type' attribute");
continue;
}
String title = DOMUtil.getAttribute(referenceElement, NAMESPACE_OPF, OPFAttributes.title);
if (GuideReference.COVER.equalsIgnoreCase(type)) {
continue; // cover is handled elsewhere
}
GuideReference reference = new GuideReference(resource, type, title, StringUtil.substringAfter(resourceHref, Constants.FRAGMENT_SEPARATOR_CHAR));
guide.addReference(reference);
}
}
/**
* Reads the document's spine, containing all sections in reading order.
*
* @param packageDocument
* @param epubReader
* @param book
* @param resourcesById
* @return
*/
private static Spine readSpine(Document packageDocument, EpubReader epubReader, Resources resources, Map<String, String> idMapping) {
Element spineElement = DOMUtil.getFirstElementByTagNameNS(packageDocument.getDocumentElement(), NAMESPACE_OPF, OPFTags.spine);
if (spineElement == null) {
log.warning("Element " + OPFTags.spine + " not found in package document, generating one automatically");
return generateSpineFromResources(resources);
}
Spine result = new Spine();
result.setTocResource(findTableOfContentsResource(spineElement, resources));
NodeList spineNodes = packageDocument.getElementsByTagNameNS(NAMESPACE_OPF, OPFTags.itemref);
List<SpineReference> spineReferences = new ArrayList<>(spineNodes.getLength());
for(int i = 0; i < spineNodes.getLength(); i++) {
Element spineItem = (Element) spineNodes.item(i);
String itemref = DOMUtil.getAttribute(spineItem, NAMESPACE_OPF, OPFAttributes.idref);
if(StringUtil.isEmpty(itemref)) {
log.warning("itemref with missing or empty idref"); // XXX
continue;
}
String id = idMapping.get(itemref);
if (id == null) {
id = itemref;
}
Resource resource = resources.getByIdOrHref(id);
if(resource == null) {
log.warning("resource with id \'" + id + "\' not found");
continue;
}
SpineReference spineReference = new SpineReference(resource);
if (OPFValues.no.equalsIgnoreCase(DOMUtil.getAttribute(spineItem, NAMESPACE_OPF, OPFAttributes.linear))) {
spineReference.setLinear(false);
}
spineReferences.add(spineReference);
}
result.setSpineReferences(spineReferences);
return result;
}
/**
* Creates a spine out of all resources in the resources.
* The generated spine consists of all XHTML pages in order of their href.
*
* @param resources
* @return
*/
private static Spine generateSpineFromResources(Resources resources) {
Spine result = new Spine();
List<String> resourceHrefs = new ArrayList<>();
resourceHrefs.addAll(resources.getAllHrefs());
Collections.sort(resourceHrefs, String.CASE_INSENSITIVE_ORDER);
for (String resourceHref: resourceHrefs) {
Resource resource = resources.getByHref(resourceHref);
if (resource.getMediaType() == MediatypeService.NCX) {
result.setTocResource(resource);
} else if (resource.getMediaType() == MediatypeService.XHTML) {
result.addSpineReference(new SpineReference(resource));
}
}
return result;
}
/**
* The spine tag should contain a 'toc' attribute with as value the resource id of the table of contents resource.
*
* Here we try several ways of finding this table of contents resource.
* We try the given attribute value, some often-used ones and finally look through all resources for the first resource with the table of contents mimetype.
*
* @param spineElement
* @param resourcesById
* @return
*/
private static Resource findTableOfContentsResource(Element spineElement, Resources resources) {
String tocResourceId = DOMUtil.getAttribute(spineElement, NAMESPACE_OPF, OPFAttributes.toc);
Resource tocResource = null;
if (StringUtil.isNotEmpty(tocResourceId)) {
tocResource = resources.getByIdOrHref(tocResourceId);
}
if (tocResource != null) {
return tocResource;
}
for (int i = 0; i < POSSIBLE_NCX_ITEM_IDS.length; i++) {
tocResource = resources.getByIdOrHref(POSSIBLE_NCX_ITEM_IDS[i]);
if (tocResource != null) {
return tocResource;
}
tocResource = resources.getByIdOrHref(POSSIBLE_NCX_ITEM_IDS[i].toUpperCase());
if (tocResource != null) {
return tocResource;
}
}
// get the first resource with the NCX mediatype
tocResource = resources.findFirstResourceByMediaType(MediatypeService.NCX);
if (tocResource == null) {
log.warning("Could not find table of contents resource. Tried resource with id '" + tocResourceId + "', " + Constants.DEFAULT_TOC_ID + ", " + Constants.DEFAULT_TOC_ID.toUpperCase() + " and any NCX resource.");
}
return tocResource;
}
/**
* Find all resources that have something to do with the coverpage and the cover image.
* Search the meta tags and the guide references
*
* @param packageDocument
* @return
*/
// package
static Set<String> findCoverHrefs(Document packageDocument) {
Set<String> result = new HashSet<>();
// try and find a meta tag with name = 'cover' and a non-blank id
String coverResourceId = DOMUtil.getFindAttributeValue(packageDocument, NAMESPACE_OPF,
OPFTags.meta, OPFAttributes.name, OPFValues.meta_cover,
OPFAttributes.content);
if (StringUtil.isNotEmpty(coverResourceId)) {
String coverHref = DOMUtil.getFindAttributeValue(packageDocument, NAMESPACE_OPF,
OPFTags.item, OPFAttributes.id, coverResourceId,
OPFAttributes.href);
if (StringUtil.isNotEmpty(coverHref)) {
result.add(coverHref);
} else {
result.add(coverResourceId); // maybe there was a cover href put in the cover id attribute
}
}
// try and find a reference tag with type is 'cover' and reference is not blank
String coverHref = DOMUtil.getFindAttributeValue(packageDocument, NAMESPACE_OPF,
OPFTags.reference, OPFAttributes.type, OPFValues.reference_cover,
OPFAttributes.href);
if (StringUtil.isNotEmpty(coverHref)) {
result.add(coverHref);
}
return result;
}
/**
* Finds the cover resource in the packageDocument and adds it to the book if found.
* Keeps the cover resource in the resources map
* @param packageDocument
* @param book
* @param resources
* @return
*/
private static void readCover(Document packageDocument, Book book) {
Collection<String> coverHrefs = findCoverHrefs(packageDocument);
for (String coverHref: coverHrefs) {
Resources resources = book.getResources();
Resource resource = resources.getByHref(coverHref);
if (resource == null) {
resource = findHrefResource(coverHref, resources);
if(resource != null) {
resource.setHref(coverHref);
}
log.warning("Cover resource " + coverHref + " not found in '" + book.getName() + "'");
continue;
}
if (resource.getMediaType() == MediatypeService.XHTML) {
book.setCoverPage(resource);
} else if (MediatypeService.isBitmapImage(resource.getMediaType())) {
book.setCoverImage(resource);
}
}
}
}