<commit_before>/**
 * Copyright 2013 Google, Inc.
 * Copyright 2015 Trim-marks Inc.
 * Copyright 2018 Vivliostyle Foundation
 *
 * Vivliostyle.js 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.
 *
 * Vivliostyle.js 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 Vivliostyle.js.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @fileoverview Deal with META-INF/ and .opf files in EPUB container.
 */
goog.provide('adapt.epub');

goog.require("goog.asserts");
goog.require("vivliostyle.constants");
goog.require("vivliostyle.logging");
goog.require('adapt.net');
goog.require('adapt.xmldoc');
goog.require('adapt.csscasc');
goog.require('adapt.font');
goog.require('vivliostyle.counters');
goog.require('adapt.ops');
goog.require('adapt.cfi');
goog.require('adapt.sha1');
goog.require('adapt.toc');

/**
 * @typedef {{spineIndex:number,pageIndex:number,offsetInItem:number}}
 */
adapt.epub.Position;

/**
 * @constructor
 * @extends {adapt.ops.OPSDocStore}
 */
adapt.epub.EPUBDocStore = function() {
    adapt.ops.OPSDocStore.call(this, this.makeDeobfuscatorFactory());
    /** @const */ this.plainXMLStore = adapt.xmldoc.newXMLDocStore();
    /** @const */ this.jsonStore = adapt.net.newJSONStore();
    /** @type {Object.<string,adapt.epub.OPFDoc>} */ this.opfByURL = {};
    /** @type {Object.<string,adapt.epub.OPFDoc>} */ this.primaryOPFByEPubURL = {};
    /** @type {Object.<string,function(Blob):adapt.task.Result.<Blob>>} */ this.deobfuscators = {};
    /** @type {Object.<string,!adapt.task.Result.<adapt.xmldoc.XMLDocHolder>>} */ this.documents = {};
};
goog.inherits(adapt.epub.EPUBDocStore, adapt.ops.OPSDocStore);

/**
 * @return {?function(string):?function(Blob):adapt.task.Result.<Blob>}
 */
adapt.epub.EPUBDocStore.prototype.makeDeobfuscatorFactory = function() {
    const self = this;
    return (
        /**
         * @param {string} url
         * @return {?function(Blob):adapt.task.Result.<Blob>}
         */
        url => {
            return self.deobfuscators[url];
        }
    );
};

/**
 * @param {string} url
 * @param {boolean=} opt_required
 * @param {string=} opt_message
 * @return {!adapt.task.Result.<adapt.xmldoc.XMLDocHolder>}
 */
adapt.epub.EPUBDocStore.prototype.loadAsPlainXML = function(url, opt_required, opt_message) {
    return this.plainXMLStore.load(url, opt_required, opt_message);
};

/**
 * @param {string} url
 * @return {void}
 */
adapt.epub.EPUBDocStore.prototype.startLoadingAsPlainXML = function(url) {
    this.plainXMLStore.fetch(url);
};

/**
 * @param {string} url
 * @return {!adapt.task.Result.<adapt.base.JSON>}
 */
adapt.epub.EPUBDocStore.prototype.loadAsJSON = function(url) {
    return this.jsonStore.load(url);
};

/**
 * @param {string} url
 * @return {void}
 */
adapt.epub.EPUBDocStore.prototype.startLoadingAsJSON = function(url) {
    this.jsonStore.fetch(url);
};

/**
 * @param {string} url
 * @param {boolean} haveZipMetadata
 * @return {!adapt.task.Result.<adapt.epub.OPFDoc>}
 */
adapt.epub.EPUBDocStore.prototype.loadEPUBDoc = function(url, haveZipMetadata) {
    const self = this;
    /** @type {!adapt.task.Frame.<adapt.epub.OPFDoc>} */ const frame
        = adapt.task.newFrame("loadEPUBDoc");
    if (url.substring(url.length - 1) !== "/") {
        url = `${url}/`;
    }
    if (haveZipMetadata) {
        self.startLoadingAsJSON(`${url}?r=list`);
    }
    self.startLoadingAsPlainXML(`${url}META-INF/encryption.xml`);
    const containerURL = `${url}META-INF/container.xml`;
    self.loadAsPlainXML(containerURL, true, `Failed to fetch EPUB container.xml from ${containerURL}`).then(containerXML => {
        if (!containerXML) {
            vivliostyle.logging.logger.error(`Received an empty response for EPUB container.xml ${containerURL}. This may be caused by the server not allowing cross origin requests.`);
        } else {
            const roots = containerXML.doc().child("container").child("rootfiles")
                .child("rootfile").attribute("full-path");

            for (const root of roots) {
                if (root) {
                    self.loadOPF(url, root, haveZipMetadata).thenFinish(frame);
                    return;
                }
            }

            frame.finish(null);
        }
    });
    return frame.result();
};

/**
 * @param {string} epubURL
 * @param {string} root
 * @param {boolean} haveZipMetadata
 * @return {!adapt.task.Result.<adapt.epub.OPFDoc>}
 */
adapt.epub.EPUBDocStore.prototype.loadOPF = function(epubURL, root, haveZipMetadata) {
    const self = this;
    const url = epubURL + root;
    let opf = self.opfByURL[url];
    if (opf) {
        return adapt.task.newResult(opf);
    }
    /** @type {!adapt.task.Frame.<adapt.epub.OPFDoc>} */ const frame
        = adapt.task.newFrame("loadOPF");
    self.loadAsPlainXML(url).then(opfXML => {
        if (!opfXML) {
            vivliostyle.logging.logger.error(`Received an empty response for EPUB OPF ${url}. This may be caused by the server not allowing cross origin requests.`);
        } else {
            self.loadAsPlainXML(`${epubURL}META-INF/encryption.xml`).then(encXML => {
                const zipMetadataResult = haveZipMetadata ?
                    self.loadAsJSON(`${epubURL}?r=list`) : adapt.task.newResult(null);
                zipMetadataResult.then(zipMetadata => {
                    opf = new adapt.epub.OPFDoc(self, epubURL);
                    opf.initWithXMLDoc(opfXML, encXML, zipMetadata, `${epubURL}?r=manifest`).then(() => {
                        self.opfByURL[url] = opf;
                        self.primaryOPFByEPubURL[epubURL] = opf;
                        frame.finish(opf);
                    });
                });
            });
        }
    });
    return frame.result();
};

/**
 * @param {string} url
 * @param {!Document} doc
 */
adapt.epub.EPUBDocStore.prototype.addDocument = function(url, doc) {
    const frame = adapt.task.newFrame("EPUBDocStore.load");
    const docURL = adapt.base.stripFragment(url);
    const r = this.documents[docURL] = this.parseOPSResource(
        {status: 200, url: docURL, contentType: doc.contentType, responseText: null, responseXML: doc, responseBlob: null}
    );
    r.thenFinish(frame);
    return frame.result();
};

/**
 * @override
 */
adapt.epub.EPUBDocStore.prototype.load = function(url) {
    const docURL = adapt.base.stripFragment(url);
    let r = this.documents[docURL];
    if (r) {
        return r.isPending() ? r : adapt.task.newResult(r.get());
    } else {
        const frame = adapt.task.newFrame("EPUBDocStore.load");
        r = adapt.epub.EPUBDocStore.superClass_.load.call(this, docURL, true, `Failed to fetch a source document from ${docURL}`);
        r.then(xmldoc => {
            if (!xmldoc) {
                vivliostyle.logging.logger.error(`Received an empty response for ${docURL}. This may be caused by the server not allowing cross origin requests.`);
            } else {
                frame.finish(xmldoc);
            }
        });
        return frame.result();
    }
};

/**
 * @typedef {{
 *     url: string,
 *     index: number,
 *     startPage: ?number,
 *     skipPagesBefore: ?number
 * }}
 */
adapt.epub.OPFItemParam;

/**
 * @constructor
 */
adapt.epub.OPFItem = function() {
    /** @type {?string} */ this.id = null;
    /** @type {string} */ this.src = "";
    /** @type {?string} */ this.mediaType = null;
    /** @type {Element} */ this.itemRefElement = null;
    /** @type {number} */ this.spineIndex = -1;
    /** @type {number} */ this.compressedSize = 0;
    /** @type {?boolean} */ this.compressed = null;
    /** @type {number} */ this.epage = 0;
    /** @type {number} */ this.epageCount = 0;
    /** @type {?number} */ this.startPage = null;
    /** @type {?number} */ this.skipPagesBefore = null;
    /** @type {Object.<string,boolean>} */ this.itemProperties = adapt.base.emptyObj;
};

/**
 * @param {Element} itemElem
 * @param {string} opfURL
 * @return {void}
 */
adapt.epub.OPFItem.prototype.initWithElement = function(itemElem, opfURL) {
    this.id = itemElem.getAttribute("id");
    this.src = adapt.base.resolveURL(itemElem.getAttribute("href"), opfURL);
    this.mediaType = itemElem.getAttribute("media-type");
    const propStr = itemElem.getAttribute("properties");
    if (propStr) {
        this.itemProperties = adapt.base.arrayToSet(propStr.split(/\s+/));
    }
};

/**
 * @param {!adapt.epub.OPFItemParam} param
 */
adapt.epub.OPFItem.prototype.initWithParam = function(param) {
    this.spineIndex = param.index;
    this.id = `item${param.index+1}`;
    this.src = param.url;
    this.startPage = param.startPage;
    this.skipPagesBefore = param.skipPagesBefore;
};

/**
 * @param {adapt.epub.OPFItem} item
 * @return {?string}
 */
adapt.epub.getOPFItemId = item => item.id;

/**
 * @param {string} uid
 * @return {function(Blob):adapt.task.Result.<Blob>}
 */
adapt.epub.makeDeobfuscator = uid => {
    // TODO: use UTF8 of uid
    const sha1 = adapt.sha1.bytesToSHA1Int8(uid);
    return blob => {
        const frame = /** @type {adapt.task.Frame.<Blob>} */ (adapt.task.newFrame("deobfuscator"));
        let head;
        let tail;
        if (blob.slice) {
            head = blob.slice(0, 1040);
            tail = blob.slice(1040, blob.size);
        } else {
            head = blob["webkitSlice"](0, 1040);
            tail = blob["webkitSlice"](1040, blob.size - 1040);
        }
        adapt.net.readBlob(head).then(buf => {
            const dataView = new DataView(buf);
            for (let k = 0; k < dataView.byteLength; k++) {
                let b = dataView.getUint8(k);
                b ^= sha1[k % 20];
                dataView.setUint8(k, b);
            }
            frame.finish(adapt.net.makeBlob([dataView, tail]));
        });
        return frame.result();
    };
};

/**
 * @param {string} uid
 * @return {string}
 */
adapt.epub.makeObfuscationKey = uid => `1040:${adapt.sha1.bytesToSHA1Hex(uid)}`;

/**
 * @typedef {{
 *   name: string,
 *   value: string,
 *   id: ?string,
 *   refines: ?string,
 *   scheme: ?string,
 *   lang: ?string,
 *   order: number
 * }}
 */
adapt.epub.RawMetaItem;

/**
 * @const
 */
adapt.epub.predefinedPrefixes = {
    "dcterms": "http://purl.org/dc/terms/",
    "marc": "http://id.loc.gov/vocabulary/",
    "media": "http://www.idpf.org/epub/vocab/overlays/#",
    "rendition": "http://www.idpf.org/vocab/rendition/#",
    "onix":	"http://www.editeur.org/ONIX/book/codelists/current.html#",
    "xsd": "http://www.w3.org/2001/XMLSchema#"
};

/**
 * @const
 */
adapt.epub.defaultIRI = "http://idpf.org/epub/vocab/package/#";


/**
 * @const
 */
adapt.epub.metaTerms = {
    language: `${adapt.epub.predefinedPrefixes["dcterms"]}language`,
    title: `${adapt.epub.predefinedPrefixes["dcterms"]}title`,
    creator: `${adapt.epub.predefinedPrefixes["dcterms"]}creator`,
    layout: `${adapt.epub.predefinedPrefixes["rendition"]}layout`,
    titleType: `${adapt.epub.defaultIRI}title-type`,
    displaySeq: `${adapt.epub.defaultIRI}display-seq`,
    alternateScript: `${adapt.epub.defaultIRI}alternate-script`
};

/**
 * @param {string} term
 * @param {string} lang
 * @return {function(adapt.base.JSON,adapt.base.JSON):number}
 */
adapt.epub.getMetadataComparator = (term, lang) => {
    const empty = {};
    return (item1, item2) => {
        let m1;
        let m2;
        const r1 = item1["r"] || empty;
        const r2 = item2["r"] || empty;
        if (term == adapt.epub.metaTerms.title) {
            m1 = r1[adapt.epub.metaTerms.titleType] == "main";
            m2 = r2[adapt.epub.metaTerms.titleType] == "main";
            if (m1 != m2) {
                return m1 ? -1 : 1;
            }
        }
        let i1 = parseInt(r1[adapt.epub.metaTerms.displaySeq], 10);
        if (isNaN(i1)) {
            i1 = Number.MAX_VALUE;
        }
        let i2 = parseInt(r2[adapt.epub.metaTerms.displaySeq], 10);
        if (isNaN(i2)) {
            i2 = Number.MAX_VALUE;
        }
        if (i1 != i2) {
            return i1 - i2;
        }
        if (term != adapt.epub.metaTerms.language && lang) {
            m1 = (r1[adapt.epub.metaTerms.language] || r1[adapt.epub.metaTerms.alternateScript]) == lang;
            m2 = (r2[adapt.epub.metaTerms.language] || r2[adapt.epub.metaTerms.alternateScript]) == lang;
            if (m1 != m2) {
                return m1 ? -1 : 1;
            }
        }
        return item1["o"] - item2["o"];
    };
};


/**
 * @param {adapt.xmldoc.NodeList} mroot
 * @param {?string} prefixes
 * @return {adapt.base.JSON}
 */
adapt.epub.readMetadata = (mroot, prefixes) => {
    // Parse prefix map (if any)
    let prefixMap;
    if (!prefixes) {
        prefixMap = adapt.epub.predefinedPrefixes;
    } else {
        prefixMap = {};
        for (const pn in adapt.epub.predefinedPrefixes) {
            prefixMap[pn] = adapt.epub.predefinedPrefixes[pn];
        }
        let r;
        // This code permits any non-ASCII characters in the name to avoid bloating the pattern.
        while ((r = prefixes.match(/^\s*([A-Z_a-z\u007F-\uFFFF][-.A-Z_a-z0-9\u007F-\uFFFF]*):\s*(\S+)/)) != null) {
            prefixes = prefixes.substr(r[0].length);
            prefixMap[r[1]] = r[2];
        }
    }
    /**
     * @param {?string} val
     * @return {?string}
     */
    const resolveProperty = val => {
        if (val) {
            const r = val.match(/^\s*(([^:]*):)?(\S+)\s*$/);
            if (r) {
                const iri = r[2] ? prefixMap[r[2]] : adapt.epub.defaultIRI;
                if (iri) {
                    return iri + r[3];
                }
            }
        }
        return null;
    };
    let order = 1;
    // List of metadata items.
    const rawItems = mroot.childElements().forEachNonNull(node => {
        if (node.localName == "meta") {
            const p = resolveProperty(node.getAttribute("property"));
            if (p) {
                return {name: p,
                    value: node.textContent, id: node.getAttribute("id"), order: order++,
                    refines: node.getAttribute("refines"), lang:null,
                    scheme: resolveProperty(node.getAttribute("scheme"))};
            }
        } else if (node.namespaceURI == adapt.base.NS.DC) {
            return {name: adapt.epub.predefinedPrefixes["dcterms"] + node.localName, order: order++,
                lang: node.getAttribute("xml:lang"),
                value: node.textContent, id: node.getAttribute("id"), refines:null, scheme:null};
        }
        return null;
    });
    // Items grouped by their target id.
    const rawItemsByTarget = adapt.base.multiIndexArray(rawItems,
        rawItem => rawItem.refines);
    const makeMetadata = map => adapt.base.mapObj(map, (rawItemArr, itemName) => {
        const result = adapt.base.map(rawItemArr, rawItem => {
            const entry = {"v": rawItem.value, "o": rawItem.order};
            if (rawItem.schema) {
                entry["s"] = rawItem.scheme;
            }
            if (rawItem.id || rawItem.lang) {
                let refs = rawItemsByTarget[rawItem.id];
                if (refs || rawItem.lang) {
                    if (rawItem.lang) {
                        // Special handling for xml:lang
                        const langItem = {name:adapt.epub.metaTerms.language, value:rawItem.lang,
                            lang:null, id:null, refines:rawItem.id, scheme:null,
                            order: rawItem.order};
                        if (refs) {
                            refs.push(langItem);
                        } else {
                            refs = [langItem];
                        }
                    }
                    const entryMap = adapt.base.multiIndexArray(refs, rawItem => rawItem.name);
                    entry["r"] = makeMetadata(entryMap);
                }
            }
            return entry;
        });
        return result;
    });
    const metadata = makeMetadata(adapt.base.multiIndexArray(rawItems,
        rawItem => rawItem.refines ? null : rawItem.name));
    let lang = null;
    if (metadata[adapt.epub.metaTerms.language]) {
        lang = metadata[adapt.epub.metaTerms.language][0]["v"];
    }
    const sortMetadata = metadata => {
        for (const term in metadata) {
            const arr = /** @type {Array} */ (metadata[term]);
            arr.sort(adapt.epub.getMetadataComparator(term, lang));
            for (let i = 0; i < arr.length; i++) {
                const r = arr[i]["r"];
                if (r) {
                    sortMetadata(r);
                }
            }
        }
    };
    sortMetadata(metadata);
    return metadata;
};

/**
 * @return {Object}
 */
adapt.epub.getMathJaxHub = () => {
    const math = window["MathJax"];
    if (math) {
        return math["Hub"];
    }
    return null;
};

/**
 * @return {void}
 */
adapt.epub.checkMathJax = () => {
    if (adapt.epub.getMathJaxHub()) {
        adapt.csscasc.supportedNamespaces[adapt.base.NS.MATHML] = true;
    }
};

/** @const */
adapt.epub.supportedMediaTypes = {
    "appliaction/xhtml+xml": true,
    "image/jpeg": true,
    "image/png": true,
    "image/svg+xml": true,
    "image/gif": true,
    "audio/mp3": true
};

/** @private @const */
adapt.epub.transformedIdPrefix = "viv-id-";

/**
 * @constructor
 * @param {adapt.epub.EPUBDocStore} store
 * @param {string} epubURL
 */
adapt.epub.OPFDoc = function(store, epubURL) {
    /** @const */ this.store = store;
    /** @type {adapt.xmldoc.XMLDocHolder} */ this.opfXML = null;
    /** @type {adapt.xmldoc.XMLDocHolder} */ this.encXML = null;
    /** @type {Array.<adapt.epub.OPFItem>} */ this.items = null;
    /** @type {Array.<adapt.epub.OPFItem>} */ this.spine = null;
    /** @type {Object.<string,adapt.epub.OPFItem>} */ this.itemMap = null;
    /** @type {Object.<string,adapt.epub.OPFItem>} */ this.itemMapByPath = null;
    /** @const */ this.epubURL = epubURL;
    /** @type {?string} */ this.uid = null;
    /** @type {Object.<string,string>} */ this.bindings = {};
    /** @type {?string} */ this.lang = null;
    /** @type {number} */ this.epageCount = 0;
    /** @type {boolean} */ this.prePaginated = false;
    /** @type {boolean} */ this.epageIsRenderedPage = false;
    /** @type {?function(number)} */ this.epageCountCallback = null;
    /** @type {adapt.base.JSON} */ this.metadata = {};
    /** @type {adapt.epub.OPFItem} */ this.ncxToc = null;
    /** @type {adapt.epub.OPFItem} */ this.xhtmlToc = null;
    /** @type {adapt.epub.OPFItem} */ this.cover = null;
    /** @type {Object.<string,string>} */ this.fallbackMap = {};
    /** @type {?vivliostyle.constants.PageProgression} */ this.pageProgression = null;
    /** @const */ this.documentURLTransformer = this.createDocumentURLTransformer();
    adapt.epub.checkMathJax();
};

adapt.epub.OPFDoc.prototype.createDocumentURLTransformer = function() {
    const self = this;

    /**
     * @implements {adapt.base.DocumentURLTransformer}
     */
    class OPFDocumentURLTransformer {
        /**
         * @override
         */
        transformFragment(fragment, baseURL) {
            const url = baseURL + (fragment ? `#${fragment}` : "");
            return adapt.epub.transformedIdPrefix + adapt.base.escapeNameStrToHex(url, ":");
        }

        /**
         * @override
         */
        transformURL(url, baseURL) {
            const r = url.match(/^([^#]*)#?(.*)$/);
            if (r) {
                const path = r[1] || baseURL;
                const fragment = r[2];
                if (path) {
                    if (self.items.some(item => item.src === path)) {
                        return `#${this.transformFragment(fragment, path)}`;
                    }
                }
            }
            return url;
        }

        /**
         * @override
         */
        restoreURL(encoded) {
            if (encoded.charAt(0) === "#") {
                encoded = encoded.substring(1);
            }
            if (encoded.indexOf(adapt.epub.transformedIdPrefix) === 0) {
                encoded = encoded.substring(adapt.epub.transformedIdPrefix.length);
            }
            const decoded = adapt.base.unescapeStrFromHex(encoded, ":");
            const r = decoded.match(/^([^#]*)#?(.*)$/);
            return r ? [r[1], r[2]] : [];
        }
    }

    return new OPFDocumentURLTransformer();
};

/**
 * Metadata is organized in the following way: fully-expanded property names (with IRI
 * prefixes prepended) point to an array of values. Array contains at least one element.
 * First element is primary and should be used by default.
 * Element values are objects have the following keys:
 * - "v" - item value as string,
 * - "s" - scheme,
 * - "o" - index in the order of appearing in the source,
 * - "r" - refinement submetadata (organized just like the top-level metadata).
 * @return {adapt.base.JSON}
 */
adapt.epub.OPFDoc.prototype.getMetadata = function() {
    return this.metadata;
};

/**
 * @param {string} url
 * @return {?string}
 */
adapt.epub.OPFDoc.prototype.getPathFromURL = function(url) {
    if (this.epubURL) {
        return url.substr(0, this.epubURL.length) == this.epubURL ?
            decodeURI(url.substr(this.epubURL.length)) : null;
    } else {
        return url;
    }
};

/**
 * @param {adapt.xmldoc.XMLDocHolder} opfXML
 * @param {adapt.xmldoc.XMLDocHolder} encXML
 * @param {adapt.base.JSON} zipMetadata
 * @param {string} manifestURL
 * @return {adapt.task.Result}
 */
adapt.epub.OPFDoc.prototype.initWithXMLDoc = function(opfXML, encXML, zipMetadata, manifestURL) {
    const self = this;
    this.opfXML = opfXML;
    this.encXML = encXML;
    const pkg = opfXML.doc().child("package");
    const uidref = pkg.attribute("unique-identifier")[0];
    if (uidref) {
        const uidElem = opfXML.getElement(`${opfXML.url}#${uidref}`);
        if (uidElem) {
            this.uid = uidElem.textContent.replace(/[ \n\r\t]/g, '');
        }
    }
    const srcToFallbackId = {};
    this.items = adapt.base.map(pkg.child("manifest").child("item").asArray(),
        node => {
            const item = new adapt.epub.OPFItem();
            const elem = /** @type {Element} */ (node);
            item.initWithElement(elem, opfXML.url);
            const fallback = elem.getAttribute("fallback");
            if (fallback && !adapt.epub.supportedMediaTypes[item.mediaType]) {
                srcToFallbackId[item.src] = fallback;
            }
            if (!self.xhtmlToc && item.itemProperties["nav"]) {
                self.xhtmlToc = item;
            }
            if (!self.cover && item.itemProperties["cover-image"]) {
                self.cover = item;
            }
            return item;
        });
    this.itemMap = adapt.base.indexArray(this.items,
        /** @type {function(adapt.epub.OPFItem):?string} */ (adapt.epub.getOPFItemId));
    this.itemMapByPath = adapt.base.indexArray(this.items, item => self.getPathFromURL(item.src));
    for (const src in srcToFallbackId) {
        let fallbackSrc = src;
        while (true) {
            var item = this.itemMap[srcToFallbackId[fallbackSrc]];
            if (!item) {
                break;
            }
            if (adapt.epub.supportedMediaTypes[item.mediaType]) {
                this.fallbackMap[src] = item.src;
                break;
            }
            fallbackSrc = item.src;
        }
    }
    this.spine = adapt.base.map(pkg.child("spine").child("itemref").asArray(),
        (node, index) => {
            const elem = /** @type {Element} */ (node);
            const id = elem.getAttribute("idref");
            const item = self.itemMap[/** @type {string} */ (id)];
            if (item) {
                item.itemRefElement = elem;
                item.spineIndex = index;
            }
            return item;
        });
    const tocAttr = pkg.child("spine").attribute("toc")[0];
    if (tocAttr) {
        this.ncxToc = this.itemMap[tocAttr];
    }
    const pageProgressionAttr = pkg.child("spine").attribute("page-progression-direction")[0];
    if (pageProgressionAttr) {
        this.pageProgression = vivliostyle.constants.PageProgression.of(pageProgressionAttr);
    }
    const idpfObfURLs = !encXML ? [] : encXML.doc().child("encryption").child("EncryptedData")
        .predicate(adapt.xmldoc.predicate.withChild("EncryptionMethod",
            adapt.xmldoc.predicate.withAttribute("Algorithm",
                "http://www.idpf.org/2008/embedding")))
        .child("CipherData").child("CipherReference").attribute("URI");
    const mediaTypeElems = pkg.child("bindings").child("mediaType").asArray();
    for (var i = 0; i < mediaTypeElems.length; i++) {
        const handlerId = mediaTypeElems[i].getAttribute("handler");
        var mediaType = mediaTypeElems[i].getAttribute("media-type");
        if (mediaType && handlerId && this.itemMap[handlerId]) {
            this.bindings[mediaType] = this.itemMap[handlerId].src;
        }
    }
    this.metadata = adapt.epub.readMetadata(pkg.child("metadata"), pkg.attribute("prefix")[0]);
    if (this.metadata[adapt.epub.metaTerms.language]) {
        this.lang = this.metadata[adapt.epub.metaTerms.language][0]["v"];
    }
    if (this.metadata[adapt.epub.metaTerms.layout]) {
        this.prePaginated = this.metadata[adapt.epub.metaTerms.layout][0]["v"] === "pre-paginated";
    }

    if (!zipMetadata) {
        if (idpfObfURLs.length > 0 && this.uid) {
            // Have to deobfuscate in JavaScript
            const deobfuscator = adapt.epub.makeDeobfuscator(this.uid);
            for (var i = 0; i < idpfObfURLs.length; i++) {
                this.store.deobfuscators[this.epubURL + idpfObfURLs[i]] = deobfuscator;
            }
        }
        if (this.prePaginated) {
            this.assignAutoPages();
        }
        return adapt.task.newResult(true);
    }
    const manifestText = new adapt.base.StringBuffer();
    const obfuscations = {};
    if (idpfObfURLs.length > 0 && this.uid) {
        // Deobfuscate in the server.
        const obfuscationKey = adapt.epub.makeObfuscationKey(this.uid);
        for (var i = 0; i < idpfObfURLs.length; i++) {
            obfuscations[idpfObfURLs[i]] = obfuscationKey;
        }
    }
    for (var i = 0; i < zipMetadata.length; i++) {
        const entry = zipMetadata[i];
        const encodedPath = entry["n"];
        if (encodedPath) {
            const path = decodeURI(encodedPath);
            var item = this.itemMapByPath[path];
            var mediaType = null;
            if (item) {
                item.compressed = entry["m"] != 0;
                item.compressedSize = entry["c"];
                if (item.mediaType) {
                    mediaType = item.mediaType.replace(/\s+/g, "");
                }
            }
            const obfuscation = obfuscations[path];
            if (mediaType || obfuscation) {
                manifestText.append(encodedPath);
                manifestText.append(' ');
                manifestText.append(mediaType || "application/octet-stream");
                if (obfuscation) {
                    manifestText.append(' ');
                    manifestText.append(obfuscation);
                }
                manifestText.append('\n');
            }
        }
    }
    self.assignAutoPages();
    return adapt.net.ajax(manifestURL, adapt.net.XMLHttpRequestResponseType.DEFAULT, "POST", manifestText.toString(), "text/plain");
};

/**
 * @return {void}
 */
adapt.epub.OPFDoc.prototype.assignAutoPages = function() {
    let epage = 0;

    for (const item of this.spine) {
        const epageCount = this.prePaginated ? 1 : Math.ceil(item.compressedSize / 1024);
        item.epage = epage;
        item.epageCount = epageCount;
        epage += epageCount;
    }

    this.epageCount = epage;

    if (this.epageCountCallback) {
        this.epageCountCallback(this.epageCount);
    }
};

/**
 * @param {boolean} epageIsRenderedPage
 * @param {?function(number)} epageCountCallback
 * @return {!adapt.task.Result.<boolean>}
 */
adapt.epub.OPFDoc.prototype.countPages = function(epageIsRenderedPage, epageCountCallback) {
    this.epageIsRenderedPage = epageIsRenderedPage || this.prePaginated;
    this.epageCountCallback = epageCountCallback;

    if (this.epageIsRenderedPage) {
        if (this.prePaginated && this.epageCount == 0) {
            this.assignAutoPages();
        }
        return adapt.task.newResult(true);
    }

    let epage = 0;
    let i = 0;
    /** @type {!adapt.task.Frame.<boolean>} */ const frame = adapt.task.newFrame("estimatePageCount");
    frame.loopWithFrame(loopFrame => {
        if (i === this.spine.length) {
            loopFrame.breakLoop();
            return;
        }
        const item = this.spine[i++];
        item.epage = epage;
        this.store.load(item.src).then(xmldoc => {
            // According to the old comment,
            // "Estimate that offset=2700 roughly corresponds to 1024 bytes of compressed size."
            // However, it should depend on the language.
            // Further adjustment needed.

            //let offsetPerEPage = 2700;
            let offsetPerEPage = 1800;
            const lang = xmldoc.lang || this.lang;
            if (lang && lang.match(/^(ja|ko|zh)/)) {
                offsetPerEPage /= 3;
            }
            item.epageCount = Math.ceil(xmldoc.getTotalOffset() / offsetPerEPage);
            epage += item.epageCount;
            this.epageCount = epage;
            if (this.epageCountCallback) {
                this.epageCountCallback(this.epageCount);
            }
            loopFrame.continueLoop();
        });
    }).thenFinish(frame);
    return frame.result();
};

/**
 * Creates a fake OPF "document" that contains OPS chapters.
 * @param {!Array<!adapt.epub.OPFItemParam>} params
 * @param {?Document} doc
 */
adapt.epub.OPFDoc.prototype.initWithChapters = function(params, doc) {
    this.itemMap = {};
    this.itemMapByPath = {};
    this.items = [];
    this.spine = this.items;
    // create a minimum fake OPF XML for navigation with EPUB CFI
    const opfXML = this.opfXML = new adapt.xmldoc.XMLDocHolder(null, "", new DOMParser().parseFromString("<spine></spine>", "text/xml"));

    params.forEach(function(param) {
        const item = new adapt.epub.OPFItem();
        item.initWithParam(param);
        goog.asserts.assert(item.id);

        const itemref = opfXML.document.createElement("itemref");
        itemref.setAttribute("idref", item.id);
        opfXML.root.appendChild(itemref);
        item.itemRefElement = itemref;

        this.itemMap[item.id] = item;
        this.itemMapByPath[param.url] = item;
        this.items.push(item);
    }, this);

    if (doc) {
        return this.store.addDocument(params[0].url, doc);
    } else {
        return adapt.task.newResult(null);
    }
};

/**
 * @param {number} spineIndex
 * @param {number} offsetInItem
 * @return {!adapt.task.Result.<?string>} cfi
 */
adapt.epub.OPFDoc.prototype.getCFI = function(spineIndex, offsetInItem) {
    const item = this.spine[spineIndex];
    /** @type {!adapt.task.Frame.<?string>} */ const frame = adapt.task.newFrame("getCFI");
    this.store.load(item.src).then(xmldoc => {
        const node = xmldoc.getNodeByOffset(offsetInItem);
        let cfi = null;
        if (node) {
            const startOffset = xmldoc.getNodeOffset(node, 0, false);
            const offsetInNode = offsetInItem - startOffset;
            const fragment = new adapt.cfi.Fragment();
            fragment.prependPathFromNode(node, offsetInNode, false, null);
            if (item.itemRefElement) {
                fragment.prependPathFromNode(item.itemRefElement, 0, false, null);
            }
            cfi = fragment.toString();
        }
        frame.finish(cfi);
    });
    return frame.result();
};

/**
 * @param {?string} fragstr
 * @return {!adapt.task.Result.<?adapt.epub.Position>}
 */
adapt.epub.OPFDoc.prototype.resolveFragment = function(fragstr) {
    const self = this;
    return adapt.task.handle("resolveFragment",
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @return {void}
         */
        frame => {
            if (!fragstr) {
                frame.finish(null);
                return;
            }
            let fragment = new adapt.cfi.Fragment();
            fragment.fromString(fragstr);
            let item;
            if (self.opfXML) {
                const opfNav = fragment.navigate(self.opfXML.document);
                if (opfNav.node.nodeType != 1 || opfNav.after || !opfNav.ref) {
                    frame.finish(null);
                    return;
                }
                const elem = /** @type {Element} */ (opfNav.node);
                const idref = elem.getAttribute("idref");
                if (elem.localName != "itemref" || !idref || !self.itemMap[idref]) {
                    frame.finish(null);
                    return;
                }
                item = self.itemMap[idref];
                fragment = opfNav.ref;
            } else {
                item = self.spine[0];
            }
            self.store.load(item.src).then(xmldoc => {
                const nodeNav = fragment.navigate(xmldoc.document);
                const offset = xmldoc.getNodeOffset(nodeNav.node, nodeNav.offset, nodeNav.after);
                frame.finish({spineIndex: item.spineIndex, offsetInItem: offset, pageIndex: -1});
            });
        },
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @param {Error} err
         * @return {void}
         */
        (frame, err) => {
            vivliostyle.logging.logger.warn(err, "Cannot resolve fragment:", fragstr);
            frame.finish(null);
        });
};

/**
 * @param {number} epage
 * @return {!adapt.task.Result.<?adapt.epub.Position>}
 */
adapt.epub.OPFDoc.prototype.resolveEPage = function(epage) {
    const self = this;
    return adapt.task.handle("resolveEPage",
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @return {void}
         */
        frame => {
            if (epage <= 0) {
                frame.finish({spineIndex: 0, offsetInItem: 0, pageIndex: -1});
                return;
            }
            if (self.epageIsRenderedPage) {
                let spineIndex = self.spine.findIndex(item => {
                    return item.epage == 0 && item.epageCount == 0 ||
                        item.epage <= epage && item.epage + item.epageCount > epage;
                });
                let item = self.spine[spineIndex];
                if (!item || item.epageCount == 0) {
                    item = self.spine[--spineIndex];
                }
                frame.finish({spineIndex, offsetInItem: -1, pageIndex: epage - item.epage});
                return;
            }
            let spineIndex = adapt.base.binarySearch(self.spine.length, index => {
                const item = self.spine[index];
                return item.epage + item.epageCount > epage;
            });
            if (spineIndex == self.spine.length) {
                spineIndex--;
            }
            const item = self.spine[spineIndex];
            self.store.load(item.src).then(xmldoc => {
                epage -= item.epage;
                if (epage > item.epageCount) {
                    epage = item.epageCount;
                }
                let offset = 0;
                if (epage > 0) {
                    const totalOffset = xmldoc.getTotalOffset();
                    offset = Math.round(totalOffset * epage / item.epageCount);
                    if (offset == totalOffset) {
                        offset--;
                    }
                }
                frame.finish({spineIndex, offsetInItem: offset, pageIndex: -1});
            });
        },
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @param {Error} err
         * @return {void}
         */
        (frame, err) => {
            vivliostyle.logging.logger.warn(err, "Cannot resolve epage:", epage);
            frame.finish(null);
        });
};

/**
 * @param {adapt.epub.Position} position
 * @return {!adapt.task.Result.<number>}
 */
adapt.epub.OPFDoc.prototype.getEPageFromPosition = function(position) {
    const item = this.spine[position.spineIndex];
    if (this.epageIsRenderedPage) {
        const epage = item.epage + position.pageIndex;
        if (this.epageCount <= epage) {
            // This may happen when finishPageContainer() is called befre this.epageIsRenderedPage is set.
            this.epageCount = epage + 1;
        }
        return adapt.task.newResult(epage);
    }
    if (position.offsetInItem <= 0) {
        return adapt.task.newResult(item.epage);
    }
    /** @type {!adapt.task.Frame.<number>} */ const frame = adapt.task.newFrame("getEPage");
    this.store.load(item.src).then(xmldoc => {
        const totalOffset = xmldoc.getTotalOffset();
        const offset = Math.min(totalOffset, position.offsetInItem);
        frame.finish(item.epage + offset * item.epageCount / totalOffset);
    });
    return frame.result();
};

/**
 * @typedef {{page: !adapt.vtree.Page, position: !adapt.epub.Position}}
 */
adapt.epub.PageAndPosition;

/**
 * @param {!adapt.vtree.Page} page
 * @param {number} pageIndex
 * @returns {!adapt.epub.PageAndPosition}
 */
adapt.epub.makePageAndPosition = (page, pageIndex) => ({
    page,

    position: {
        spineIndex: page.spineIndex,
        pageIndex,
        offsetInItem: page.offset
    }
});

/**
 * @typedef {{
 * 		item: adapt.epub.OPFItem,
 * 		xmldoc: adapt.xmldoc.XMLDocHolder,
 * 		instance: adapt.ops.StyleInstance,
 * 		layoutPositions: Array.<adapt.vtree.LayoutPosition>,
 * 		pages: !Array.<adapt.vtree.Page>,
 * 		complete: boolean
 * }}
 */
adapt.epub.OPFViewItem;

/**
 * @constructor
 * @param {adapt.epub.OPFDoc} opf
 * @param {adapt.vgen.Viewport} viewport
 * @param {adapt.font.Mapper} fontMapper
 * @param {adapt.expr.Preferences} pref
 * @param {!function({width: number, height: number}, !Object<string, !{width: number, height: number}>, number, number)} pageSheetSizeReporter
 * @implements {adapt.vgen.CustomRendererFactory}
 */
adapt.epub.OPFView = function(opf, viewport, fontMapper, pref, pageSheetSizeReporter) {
    /** @const */ this.opf = opf;
    /** @const */ this.viewport = viewport;
    /** @const */ this.fontMapper = fontMapper;
    /** @const */ this.pageSheetSizeReporter = pageSheetSizeReporter;
    /** @type {Array.<adapt.epub.OPFViewItem>} */ this.spineItems = [];
    /** @const {Array.<Array.<adapt.task.Continuation>>} */ this.spineItemLoadingContinuations = [];
    /** @const */ this.pref = adapt.expr.clonePreferences(pref);
    /** @const */ this.clientLayout = new adapt.vgen.DefaultClientLayout(viewport);
    /** @const */ this.counterStore = new vivliostyle.counters.CounterStore(opf.documentURLTransformer);
};

/**
 * @private
 * @param {!adapt.epub.Position} position
 * @returns {adapt.vtree.Page}
 */
adapt.epub.OPFView.prototype.getPage = function(position) {
    const viewItem = this.spineItems[position.spineIndex];
    return viewItem ? viewItem.pages[position.pageIndex] : null;
};

/**
 * @param {adapt.epub.Position} position
 * @returns {?vivliostyle.constants.PageProgression}
 */
adapt.epub.OPFView.prototype.getCurrentPageProgression = function(position) {
    if (this.opf.pageProgression) {
        return this.opf.pageProgression;
    } else {
        const viewItem = this.spineItems[position ? position.spineIndex : 0];
        return viewItem ? viewItem.instance.pageProgression : null;
    }
};

/**
 * @private
 * @param {adapt.epub.OPFViewItem} viewItem
 * @param {adapt.vtree.Page} page
 * @param {number} pageIndex
 */
adapt.epub.OPFView.prototype.finishPageContainer = function(viewItem, page, pageIndex) {
    page.container.style.display = "none";
    page.container.style.visibility = "visible";
    page.container.style.position = "";
    page.container.style.top = "";
    page.container.style.left = "";
    page.container.setAttribute("data-vivliostyle-page-side", /** @type {string} */ (page.side));
    const oldPage = viewItem.pages[pageIndex];
    page.isFirstPage = viewItem.item.spineIndex == 0 && pageIndex == 0;
    viewItem.pages[pageIndex] = page;

    if (this.opf.epageIsRenderedPage) {
        if (pageIndex == 0 && viewItem.item.spineIndex > 0) {
            const prevItem = this.opf.spine[viewItem.item.spineIndex - 1];
            viewItem.item.epage = prevItem.epage + prevItem.epageCount;
        }
        viewItem.item.epageCount = viewItem.pages.length;
        this.opf.epageCount = this.opf.spine.reduce((count, item) => count + item.epageCount, 0);

        if (this.opf.epageCountCallback) {
            this.opf.epageCountCallback(this.opf.epageCount);
        }
    }

    if (oldPage) {
        viewItem.instance.viewport.contentContainer.replaceChild(page.container, oldPage.container);
        oldPage.dispatchEvent({
            type: "replaced",
            target: null,
            currentTarget: null,
            newPage: page
        });
    } else {
        // Find insert position in contentContainer.
        let insertPos = null;
        if (pageIndex > 0) {
            insertPos = viewItem.pages[pageIndex - 1].container.nextElementSibling;
        } else {
            for (let i = viewItem.item.spineIndex + 1; i < this.spineItems.length; i++) {
                const item = this.spineItems[i];
                if (item && item.pages[0]) {
                    insertPos = item.pages[0].container;
                    break;
                }
            }
        }
        viewItem.instance.viewport.contentContainer.insertBefore(page.container, insertPos);
    }
    this.pageSheetSizeReporter({ width: viewItem.instance.pageSheetWidth, height: viewItem.instance.pageSheetHeight },
                               viewItem.instance.pageSheetSize, viewItem.item.spineIndex, viewItem.instance.pageNumberOffset + pageIndex);
};

/**
 * @private
 * @typedef {{
 *      pageAndPosition: !adapt.epub.PageAndPosition,
 *      nextLayoutPosition: adapt.vtree.LayoutPosition
 * }}
 */
adapt.epub.OPFView.RenderSinglePageResult;

/**
 * Render a single page. If the new page contains elements with ids that are referenced from other pages by 'target-counter()', those pages are rendered too (calling `renderSinglePage` recursively).
 * @private
 * @param {!adapt.epub.OPFViewItem} viewItem
 * @param {adapt.vtree.LayoutPosition} pos
 * @returns {!adapt.task.Result<!adapt.epub.OPFView.RenderSinglePageResult>}
 */
adapt.epub.OPFView.prototype.renderSinglePage = function(viewItem, pos) {
    /** @type {!adapt.task.Frame<!adapt.epub.OPFView.RenderSinglePageResult>} */ const frame
        = adapt.task.newFrame("renderSinglePage");
    let page = this.makePage(viewItem, pos);
    const self = this;
    viewItem.instance.layoutNextPage(page, pos).then(posParam => {
        pos = /** @type {adapt.vtree.LayoutPosition} */ (posParam);
        const pageIndex = pos ? pos.page - 1 : viewItem.layoutPositions.length - 1;
        self.finishPageContainer(viewItem, page, pageIndex);

        self.counterStore.finishPage(page.spineIndex, pageIndex);

        // If the position of the page break change, we should re-layout the next page too.
        let cont = null;
        if (pos) {
            const prevPos = viewItem.layoutPositions[pos.page];
            viewItem.layoutPositions[pos.page] = pos;
            if (prevPos && viewItem.pages[pos.page]) {
                if (!pos.isSamePosition(prevPos)) {
                    cont = self.renderSinglePage(viewItem, pos);
                }
            }
        }
        if (!cont) {
            cont = adapt.task.newResult(true);
        }

        cont.then(() => {
            const unresolvedRefs = self.counterStore.getUnresolvedRefsToPage(page);
            let index = 0;
            frame.loopWithFrame(loopFrame => {
                index++;
                if (index > unresolvedRefs.length) {
                    loopFrame.breakLoop();
                    return;
                }
                const refs = unresolvedRefs[index - 1];
                refs.refs = refs.refs.filter(ref => !ref.isResolved());
                if (refs.refs.length === 0) {
                    loopFrame.continueLoop();
                    return;
                }

                self.getPageViewItem(refs.spineIndex).then(viewItem => {
                    if (!viewItem) {
                        loopFrame.continueLoop();
                        return;
                    }
                    self.counterStore.pushPageCounters(refs.pageCounters);
                    self.counterStore.pushReferencesToSolve(refs.refs);
                    const pos = viewItem.layoutPositions[refs.pageIndex];
                    self.renderSinglePage(viewItem, pos).then(result => {
                        self.counterStore.popPageCounters();
                        self.counterStore.popReferencesToSolve();
                        const resultPosition = result.pageAndPosition.position;
                        if (resultPosition.spineIndex === page.spineIndex && resultPosition.pageIndex === pageIndex) {
                            page = result.pageAndPosition.page;
                        }
                        loopFrame.continueLoop();
                    });
                });
            }).then(() => {
                page.isLastPage = !pos &&
                    (viewItem.item.spineIndex === self.opf.spine.length - 1);
                if (page.isLastPage) {
                    goog.asserts.assert(self.viewport);
                    self.counterStore.finishLastPage(self.viewport);
                }
                frame.finish({
                    pageAndPosition: adapt.epub.makePageAndPosition(page, pageIndex),
                    nextLayoutPosition: pos<commit_msg>minor fix<commit_after>/**
 * Copyright 2013 Google, Inc.
 * Copyright 2015 Trim-marks Inc.
 * Copyright 2018 Vivliostyle Foundation
 *
 * Vivliostyle.js 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.
 *
 * Vivliostyle.js 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 Vivliostyle.js.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @fileoverview Deal with META-INF/ and .opf files in EPUB container.
 */
goog.provide('adapt.epub');

goog.require("goog.asserts");
goog.require("vivliostyle.constants");
goog.require("vivliostyle.logging");
goog.require('adapt.net');
goog.require('adapt.xmldoc');
goog.require('adapt.csscasc');
goog.require('adapt.font');
goog.require('vivliostyle.counters');
goog.require('adapt.ops');
goog.require('adapt.cfi');
goog.require('adapt.sha1');
goog.require('adapt.toc');

/**
 * @typedef {{spineIndex:number,pageIndex:number,offsetInItem:number}}
 */
adapt.epub.Position;

/**
 * @constructor
 * @extends {adapt.ops.OPSDocStore}
 */
adapt.epub.EPUBDocStore = function() {
    adapt.ops.OPSDocStore.call(this, this.makeDeobfuscatorFactory());
    /** @const */ this.plainXMLStore = adapt.xmldoc.newXMLDocStore();
    /** @const */ this.jsonStore = adapt.net.newJSONStore();
    /** @type {Object.<string,adapt.epub.OPFDoc>} */ this.opfByURL = {};
    /** @type {Object.<string,adapt.epub.OPFDoc>} */ this.primaryOPFByEPubURL = {};
    /** @type {Object.<string,function(Blob):adapt.task.Result.<Blob>>} */ this.deobfuscators = {};
    /** @type {Object.<string,!adapt.task.Result.<adapt.xmldoc.XMLDocHolder>>} */ this.documents = {};
};
goog.inherits(adapt.epub.EPUBDocStore, adapt.ops.OPSDocStore);

/**
 * @return {?function(string):?function(Blob):adapt.task.Result.<Blob>}
 */
adapt.epub.EPUBDocStore.prototype.makeDeobfuscatorFactory = function() {
    const self = this;
    return (
        /**
         * @param {string} url
         * @return {?function(Blob):adapt.task.Result.<Blob>}
         */
        url => {
            return self.deobfuscators[url];
        }
    );
};

/**
 * @param {string} url
 * @param {boolean=} opt_required
 * @param {string=} opt_message
 * @return {!adapt.task.Result.<adapt.xmldoc.XMLDocHolder>}
 */
adapt.epub.EPUBDocStore.prototype.loadAsPlainXML = function(url, opt_required, opt_message) {
    return this.plainXMLStore.load(url, opt_required, opt_message);
};

/**
 * @param {string} url
 * @return {void}
 */
adapt.epub.EPUBDocStore.prototype.startLoadingAsPlainXML = function(url) {
    this.plainXMLStore.fetch(url);
};

/**
 * @param {string} url
 * @return {!adapt.task.Result.<adapt.base.JSON>}
 */
adapt.epub.EPUBDocStore.prototype.loadAsJSON = function(url) {
    return this.jsonStore.load(url);
};

/**
 * @param {string} url
 * @return {void}
 */
adapt.epub.EPUBDocStore.prototype.startLoadingAsJSON = function(url) {
    this.jsonStore.fetch(url);
};

/**
 * @param {string} url
 * @param {boolean} haveZipMetadata
 * @return {!adapt.task.Result.<adapt.epub.OPFDoc>}
 */
adapt.epub.EPUBDocStore.prototype.loadEPUBDoc = function(url, haveZipMetadata) {
    const self = this;
    /** @type {!adapt.task.Frame.<adapt.epub.OPFDoc>} */ const frame
        = adapt.task.newFrame("loadEPUBDoc");
    if (url.substring(url.length - 1) !== "/") {
        url = `${url}/`;
    }
    if (haveZipMetadata) {
        self.startLoadingAsJSON(`${url}?r=list`);
    }
    self.startLoadingAsPlainXML(`${url}META-INF/encryption.xml`);
    const containerURL = `${url}META-INF/container.xml`;
    self.loadAsPlainXML(containerURL, true, `Failed to fetch EPUB container.xml from ${containerURL}`).then(containerXML => {
        if (!containerXML) {
            vivliostyle.logging.logger.error(`Received an empty response for EPUB container.xml ${containerURL}. This may be caused by the server not allowing cross origin requests.`);
        } else {
            const roots = containerXML.doc().child("container").child("rootfiles")
                .child("rootfile").attribute("full-path");

            for (const root of roots) {
                if (root) {
                    self.loadOPF(url, root, haveZipMetadata).thenFinish(frame);
                    return;
                }
            }

            frame.finish(null);
        }
    });
    return frame.result();
};

/**
 * @param {string} epubURL
 * @param {string} root
 * @param {boolean} haveZipMetadata
 * @return {!adapt.task.Result.<adapt.epub.OPFDoc>}
 */
adapt.epub.EPUBDocStore.prototype.loadOPF = function(epubURL, root, haveZipMetadata) {
    const self = this;
    const url = epubURL + root;
    let opf = self.opfByURL[url];
    if (opf) {
        return adapt.task.newResult(opf);
    }
    /** @type {!adapt.task.Frame.<adapt.epub.OPFDoc>} */ const frame
        = adapt.task.newFrame("loadOPF");
    self.loadAsPlainXML(url).then(opfXML => {
        if (!opfXML) {
            vivliostyle.logging.logger.error(`Received an empty response for EPUB OPF ${url}. This may be caused by the server not allowing cross origin requests.`);
        } else {
            self.loadAsPlainXML(`${epubURL}META-INF/encryption.xml`).then(encXML => {
                const zipMetadataResult = haveZipMetadata ?
                    self.loadAsJSON(`${epubURL}?r=list`) : adapt.task.newResult(null);
                zipMetadataResult.then(zipMetadata => {
                    opf = new adapt.epub.OPFDoc(self, epubURL);
                    opf.initWithXMLDoc(opfXML, encXML, zipMetadata, `${epubURL}?r=manifest`).then(() => {
                        self.opfByURL[url] = opf;
                        self.primaryOPFByEPubURL[epubURL] = opf;
                        frame.finish(opf);
                    });
                });
            });
        }
    });
    return frame.result();
};

/**
 * @param {string} url
 * @param {!Document} doc
 */
adapt.epub.EPUBDocStore.prototype.addDocument = function(url, doc) {
    const frame = adapt.task.newFrame("EPUBDocStore.load");
    const docURL = adapt.base.stripFragment(url);
    const r = this.documents[docURL] = this.parseOPSResource(
        {status: 200, url: docURL, contentType: doc.contentType, responseText: null, responseXML: doc, responseBlob: null}
    );
    r.thenFinish(frame);
    return frame.result();
};

/**
 * @override
 */
adapt.epub.EPUBDocStore.prototype.load = function(url) {
    const docURL = adapt.base.stripFragment(url);
    let r = this.documents[docURL];
    if (r) {
        return r.isPending() ? r : adapt.task.newResult(r.get());
    } else {
        const frame = adapt.task.newFrame("EPUBDocStore.load");
        r = adapt.epub.EPUBDocStore.superClass_.load.call(this, docURL, true, `Failed to fetch a source document from ${docURL}`);
        r.then(xmldoc => {
            if (!xmldoc) {
                vivliostyle.logging.logger.error(`Received an empty response for ${docURL}. This may be caused by the server not allowing cross origin requests.`);
            } else {
                frame.finish(xmldoc);
            }
        });
        return frame.result();
    }
};

/**
 * @typedef {{
 *     url: string,
 *     index: number,
 *     startPage: ?number,
 *     skipPagesBefore: ?number
 * }}
 */
adapt.epub.OPFItemParam;

/**
 * @constructor
 */
adapt.epub.OPFItem = function() {
    /** @type {?string} */ this.id = null;
    /** @type {string} */ this.src = "";
    /** @type {?string} */ this.mediaType = null;
    /** @type {Element} */ this.itemRefElement = null;
    /** @type {number} */ this.spineIndex = -1;
    /** @type {number} */ this.compressedSize = 0;
    /** @type {?boolean} */ this.compressed = null;
    /** @type {number} */ this.epage = 0;
    /** @type {number} */ this.epageCount = 0;
    /** @type {?number} */ this.startPage = null;
    /** @type {?number} */ this.skipPagesBefore = null;
    /** @type {Object.<string,boolean>} */ this.itemProperties = adapt.base.emptyObj;
};

/**
 * @param {Element} itemElem
 * @param {string} opfURL
 * @return {void}
 */
adapt.epub.OPFItem.prototype.initWithElement = function(itemElem, opfURL) {
    this.id = itemElem.getAttribute("id");
    this.src = adapt.base.resolveURL(itemElem.getAttribute("href"), opfURL);
    this.mediaType = itemElem.getAttribute("media-type");
    const propStr = itemElem.getAttribute("properties");
    if (propStr) {
        this.itemProperties = adapt.base.arrayToSet(propStr.split(/\s+/));
    }
};

/**
 * @param {!adapt.epub.OPFItemParam} param
 */
adapt.epub.OPFItem.prototype.initWithParam = function(param) {
    this.spineIndex = param.index;
    this.id = `item${param.index+1}`;
    this.src = param.url;
    this.startPage = param.startPage;
    this.skipPagesBefore = param.skipPagesBefore;
};

/**
 * @param {adapt.epub.OPFItem} item
 * @return {?string}
 */
adapt.epub.getOPFItemId = item => item.id;

/**
 * @param {string} uid
 * @return {function(Blob):adapt.task.Result.<Blob>}
 */
adapt.epub.makeDeobfuscator = uid => {
    // TODO: use UTF8 of uid
    const sha1 = adapt.sha1.bytesToSHA1Int8(uid);
    return blob => {
        const frame = /** @type {adapt.task.Frame.<Blob>} */ (adapt.task.newFrame("deobfuscator"));
        let head;
        let tail;
        if (blob.slice) {
            head = blob.slice(0, 1040);
            tail = blob.slice(1040, blob.size);
        } else {
            head = blob["webkitSlice"](0, 1040);
            tail = blob["webkitSlice"](1040, blob.size - 1040);
        }
        adapt.net.readBlob(head).then(buf => {
            const dataView = new DataView(buf);
            for (let k = 0; k < dataView.byteLength; k++) {
                let b = dataView.getUint8(k);
                b ^= sha1[k % 20];
                dataView.setUint8(k, b);
            }
            frame.finish(adapt.net.makeBlob([dataView, tail]));
        });
        return frame.result();
    };
};

/**
 * @param {string} uid
 * @return {string}
 */
adapt.epub.makeObfuscationKey = uid => `1040:${adapt.sha1.bytesToSHA1Hex(uid)}`;

/**
 * @typedef {{
 *   name: string,
 *   value: string,
 *   id: ?string,
 *   refines: ?string,
 *   scheme: ?string,
 *   lang: ?string,
 *   order: number
 * }}
 */
adapt.epub.RawMetaItem;

/**
 * @const
 */
adapt.epub.predefinedPrefixes = {
    "dcterms": "http://purl.org/dc/terms/",
    "marc": "http://id.loc.gov/vocabulary/",
    "media": "http://www.idpf.org/epub/vocab/overlays/#",
    "rendition": "http://www.idpf.org/vocab/rendition/#",
    "onix":	"http://www.editeur.org/ONIX/book/codelists/current.html#",
    "xsd": "http://www.w3.org/2001/XMLSchema#"
};

/**
 * @const
 */
adapt.epub.defaultIRI = "http://idpf.org/epub/vocab/package/#";


/**
 * @const
 */
adapt.epub.metaTerms = {
    language: `${adapt.epub.predefinedPrefixes["dcterms"]}language`,
    title: `${adapt.epub.predefinedPrefixes["dcterms"]}title`,
    creator: `${adapt.epub.predefinedPrefixes["dcterms"]}creator`,
    layout: `${adapt.epub.predefinedPrefixes["rendition"]}layout`,
    titleType: `${adapt.epub.defaultIRI}title-type`,
    displaySeq: `${adapt.epub.defaultIRI}display-seq`,
    alternateScript: `${adapt.epub.defaultIRI}alternate-script`
};

/**
 * @param {string} term
 * @param {string} lang
 * @return {function(adapt.base.JSON,adapt.base.JSON):number}
 */
adapt.epub.getMetadataComparator = (term, lang) => {
    const empty = {};
    return (item1, item2) => {
        let m1;
        let m2;
        const r1 = item1["r"] || empty;
        const r2 = item2["r"] || empty;
        if (term == adapt.epub.metaTerms.title) {
            m1 = r1[adapt.epub.metaTerms.titleType] == "main";
            m2 = r2[adapt.epub.metaTerms.titleType] == "main";
            if (m1 != m2) {
                return m1 ? -1 : 1;
            }
        }
        let i1 = parseInt(r1[adapt.epub.metaTerms.displaySeq], 10);
        if (isNaN(i1)) {
            i1 = Number.MAX_VALUE;
        }
        let i2 = parseInt(r2[adapt.epub.metaTerms.displaySeq], 10);
        if (isNaN(i2)) {
            i2 = Number.MAX_VALUE;
        }
        if (i1 != i2) {
            return i1 - i2;
        }
        if (term != adapt.epub.metaTerms.language && lang) {
            m1 = (r1[adapt.epub.metaTerms.language] || r1[adapt.epub.metaTerms.alternateScript]) == lang;
            m2 = (r2[adapt.epub.metaTerms.language] || r2[adapt.epub.metaTerms.alternateScript]) == lang;
            if (m1 != m2) {
                return m1 ? -1 : 1;
            }
        }
        return item1["o"] - item2["o"];
    };
};


/**
 * @param {adapt.xmldoc.NodeList} mroot
 * @param {?string} prefixes
 * @return {adapt.base.JSON}
 */
adapt.epub.readMetadata = (mroot, prefixes) => {
    // Parse prefix map (if any)
    let prefixMap;
    if (!prefixes) {
        prefixMap = adapt.epub.predefinedPrefixes;
    } else {
        prefixMap = {};
        for (const pn in adapt.epub.predefinedPrefixes) {
            prefixMap[pn] = adapt.epub.predefinedPrefixes[pn];
        }
        let r;
        // This code permits any non-ASCII characters in the name to avoid bloating the pattern.
        while ((r = prefixes.match(/^\s*([A-Z_a-z\u007F-\uFFFF][-.A-Z_a-z0-9\u007F-\uFFFF]*):\s*(\S+)/)) != null) {
            prefixes = prefixes.substr(r[0].length);
            prefixMap[r[1]] = r[2];
        }
    }
    /**
     * @param {?string} val
     * @return {?string}
     */
    const resolveProperty = val => {
        if (val) {
            const r = val.match(/^\s*(([^:]*):)?(\S+)\s*$/);
            if (r) {
                const iri = r[2] ? prefixMap[r[2]] : adapt.epub.defaultIRI;
                if (iri) {
                    return iri + r[3];
                }
            }
        }
        return null;
    };
    let order = 1;
    // List of metadata items.
    const rawItems = mroot.childElements().forEachNonNull(node => {
        if (node.localName == "meta") {
            const p = resolveProperty(node.getAttribute("property"));
            if (p) {
                return {name: p,
                    value: node.textContent, id: node.getAttribute("id"), order: order++,
                    refines: node.getAttribute("refines"), lang:null,
                    scheme: resolveProperty(node.getAttribute("scheme"))};
            }
        } else if (node.namespaceURI == adapt.base.NS.DC) {
            return {name: adapt.epub.predefinedPrefixes["dcterms"] + node.localName, order: order++,
                lang: node.getAttribute("xml:lang"),
                value: node.textContent, id: node.getAttribute("id"), refines:null, scheme:null};
        }
        return null;
    });
    // Items grouped by their target id.
    const rawItemsByTarget = adapt.base.multiIndexArray(rawItems,
        rawItem => rawItem.refines);
    const makeMetadata = map => adapt.base.mapObj(map, (rawItemArr, itemName) => {
        const result = adapt.base.map(rawItemArr, rawItem => {
            const entry = {"v": rawItem.value, "o": rawItem.order};
            if (rawItem.schema) {
                entry["s"] = rawItem.scheme;
            }
            if (rawItem.id || rawItem.lang) {
                let refs = rawItemsByTarget[rawItem.id];
                if (refs || rawItem.lang) {
                    if (rawItem.lang) {
                        // Special handling for xml:lang
                        const langItem = {name:adapt.epub.metaTerms.language, value:rawItem.lang,
                            lang:null, id:null, refines:rawItem.id, scheme:null,
                            order: rawItem.order};
                        if (refs) {
                            refs.push(langItem);
                        } else {
                            refs = [langItem];
                        }
                    }
                    const entryMap = adapt.base.multiIndexArray(refs, rawItem => rawItem.name);
                    entry["r"] = makeMetadata(entryMap);
                }
            }
            return entry;
        });
        return result;
    });
    const metadata = makeMetadata(adapt.base.multiIndexArray(rawItems,
        rawItem => rawItem.refines ? null : rawItem.name));
    let lang = null;
    if (metadata[adapt.epub.metaTerms.language]) {
        lang = metadata[adapt.epub.metaTerms.language][0]["v"];
    }
    const sortMetadata = metadata => {
        for (const term in metadata) {
            const arr = /** @type {Array} */ (metadata[term]);
            arr.sort(adapt.epub.getMetadataComparator(term, lang));
            for (let i = 0; i < arr.length; i++) {
                const r = arr[i]["r"];
                if (r) {
                    sortMetadata(r);
                }
            }
        }
    };
    sortMetadata(metadata);
    return metadata;
};

/**
 * @return {Object}
 */
adapt.epub.getMathJaxHub = () => {
    const math = window["MathJax"];
    if (math) {
        return math["Hub"];
    }
    return null;
};

/**
 * @return {void}
 */
adapt.epub.checkMathJax = () => {
    if (adapt.epub.getMathJaxHub()) {
        adapt.csscasc.supportedNamespaces[adapt.base.NS.MATHML] = true;
    }
};

/** @const */
adapt.epub.supportedMediaTypes = {
    "appliaction/xhtml+xml": true,
    "image/jpeg": true,
    "image/png": true,
    "image/svg+xml": true,
    "image/gif": true,
    "audio/mp3": true
};

/** @private @const */
adapt.epub.transformedIdPrefix = "viv-id-";

/**
 * @constructor
 * @param {adapt.epub.EPUBDocStore} store
 * @param {string} epubURL
 */
adapt.epub.OPFDoc = function(store, epubURL) {
    /** @const */ this.store = store;
    /** @type {adapt.xmldoc.XMLDocHolder} */ this.opfXML = null;
    /** @type {adapt.xmldoc.XMLDocHolder} */ this.encXML = null;
    /** @type {Array.<adapt.epub.OPFItem>} */ this.items = null;
    /** @type {Array.<adapt.epub.OPFItem>} */ this.spine = null;
    /** @type {Object.<string,adapt.epub.OPFItem>} */ this.itemMap = null;
    /** @type {Object.<string,adapt.epub.OPFItem>} */ this.itemMapByPath = null;
    /** @const */ this.epubURL = epubURL;
    /** @type {?string} */ this.uid = null;
    /** @type {Object.<string,string>} */ this.bindings = {};
    /** @type {?string} */ this.lang = null;
    /** @type {number} */ this.epageCount = 0;
    /** @type {boolean} */ this.prePaginated = false;
    /** @type {boolean} */ this.epageIsRenderedPage = false;
    /** @type {?function(number)} */ this.epageCountCallback = null;
    /** @type {adapt.base.JSON} */ this.metadata = {};
    /** @type {adapt.epub.OPFItem} */ this.ncxToc = null;
    /** @type {adapt.epub.OPFItem} */ this.xhtmlToc = null;
    /** @type {adapt.epub.OPFItem} */ this.cover = null;
    /** @type {Object.<string,string>} */ this.fallbackMap = {};
    /** @type {?vivliostyle.constants.PageProgression} */ this.pageProgression = null;
    /** @const */ this.documentURLTransformer = this.createDocumentURLTransformer();
    adapt.epub.checkMathJax();
};

adapt.epub.OPFDoc.prototype.createDocumentURLTransformer = function() {
    const self = this;

    /**
     * @implements {adapt.base.DocumentURLTransformer}
     */
    class OPFDocumentURLTransformer {
        /**
         * @override
         */
        transformFragment(fragment, baseURL) {
            const url = baseURL + (fragment ? `#${fragment}` : "");
            return adapt.epub.transformedIdPrefix + adapt.base.escapeNameStrToHex(url, ":");
        }

        /**
         * @override
         */
        transformURL(url, baseURL) {
            const r = url.match(/^([^#]*)#?(.*)$/);
            if (r) {
                const path = r[1] || baseURL;
                const fragment = r[2];
                if (path) {
                    if (self.items.some(item => item.src === path)) {
                        return `#${this.transformFragment(fragment, path)}`;
                    }
                }
            }
            return url;
        }

        /**
         * @override
         */
        restoreURL(encoded) {
            if (encoded.charAt(0) === "#") {
                encoded = encoded.substring(1);
            }
            if (encoded.indexOf(adapt.epub.transformedIdPrefix) === 0) {
                encoded = encoded.substring(adapt.epub.transformedIdPrefix.length);
            }
            const decoded = adapt.base.unescapeStrFromHex(encoded, ":");
            const r = decoded.match(/^([^#]*)#?(.*)$/);
            return r ? [r[1], r[2]] : [];
        }
    }

    return new OPFDocumentURLTransformer();
};

/**
 * Metadata is organized in the following way: fully-expanded property names (with IRI
 * prefixes prepended) point to an array of values. Array contains at least one element.
 * First element is primary and should be used by default.
 * Element values are objects have the following keys:
 * - "v" - item value as string,
 * - "s" - scheme,
 * - "o" - index in the order of appearing in the source,
 * - "r" - refinement submetadata (organized just like the top-level metadata).
 * @return {adapt.base.JSON}
 */
adapt.epub.OPFDoc.prototype.getMetadata = function() {
    return this.metadata;
};

/**
 * @param {string} url
 * @return {?string}
 */
adapt.epub.OPFDoc.prototype.getPathFromURL = function(url) {
    if (this.epubURL) {
        return url.substr(0, this.epubURL.length) == this.epubURL ?
            decodeURI(url.substr(this.epubURL.length)) : null;
    } else {
        return url;
    }
};

/**
 * @param {adapt.xmldoc.XMLDocHolder} opfXML
 * @param {adapt.xmldoc.XMLDocHolder} encXML
 * @param {adapt.base.JSON} zipMetadata
 * @param {string} manifestURL
 * @return {adapt.task.Result}
 */
adapt.epub.OPFDoc.prototype.initWithXMLDoc = function(opfXML, encXML, zipMetadata, manifestURL) {
    const self = this;
    this.opfXML = opfXML;
    this.encXML = encXML;
    const pkg = opfXML.doc().child("package");
    const uidref = pkg.attribute("unique-identifier")[0];
    if (uidref) {
        const uidElem = opfXML.getElement(`${opfXML.url}#${uidref}`);
        if (uidElem) {
            this.uid = uidElem.textContent.replace(/[ \n\r\t]/g, '');
        }
    }
    const srcToFallbackId = {};
    this.items = adapt.base.map(pkg.child("manifest").child("item").asArray(),
        node => {
            const item = new adapt.epub.OPFItem();
            const elem = /** @type {Element} */ (node);
            item.initWithElement(elem, opfXML.url);
            const fallback = elem.getAttribute("fallback");
            if (fallback && !adapt.epub.supportedMediaTypes[item.mediaType]) {
                srcToFallbackId[item.src] = fallback;
            }
            if (!self.xhtmlToc && item.itemProperties["nav"]) {
                self.xhtmlToc = item;
            }
            if (!self.cover && item.itemProperties["cover-image"]) {
                self.cover = item;
            }
            return item;
        });
    this.itemMap = adapt.base.indexArray(this.items,
        /** @type {function(adapt.epub.OPFItem):?string} */ (adapt.epub.getOPFItemId));
    this.itemMapByPath = adapt.base.indexArray(this.items, item => self.getPathFromURL(item.src));
    for (const src in srcToFallbackId) {
        let fallbackSrc = src;
        while (true) {
            var item = this.itemMap[srcToFallbackId[fallbackSrc]];
            if (!item) {
                break;
            }
            if (adapt.epub.supportedMediaTypes[item.mediaType]) {
                this.fallbackMap[src] = item.src;
                break;
            }
            fallbackSrc = item.src;
        }
    }
    this.spine = adapt.base.map(pkg.child("spine").child("itemref").asArray(),
        (node, index) => {
            const elem = /** @type {Element} */ (node);
            const id = elem.getAttribute("idref");
            const item = self.itemMap[/** @type {string} */ (id)];
            if (item) {
                item.itemRefElement = elem;
                item.spineIndex = index;
            }
            return item;
        });
    const tocAttr = pkg.child("spine").attribute("toc")[0];
    if (tocAttr) {
        this.ncxToc = this.itemMap[tocAttr];
    }
    const pageProgressionAttr = pkg.child("spine").attribute("page-progression-direction")[0];
    if (pageProgressionAttr) {
        this.pageProgression = vivliostyle.constants.PageProgression.of(pageProgressionAttr);
    }
    const idpfObfURLs = !encXML ? [] : encXML.doc().child("encryption").child("EncryptedData")
        .predicate(adapt.xmldoc.predicate.withChild("EncryptionMethod",
            adapt.xmldoc.predicate.withAttribute("Algorithm",
                "http://www.idpf.org/2008/embedding")))
        .child("CipherData").child("CipherReference").attribute("URI");
    const mediaTypeElems = pkg.child("bindings").child("mediaType").asArray();
    for (var i = 0; i < mediaTypeElems.length; i++) {
        const handlerId = mediaTypeElems[i].getAttribute("handler");
        var mediaType = mediaTypeElems[i].getAttribute("media-type");
        if (mediaType && handlerId && this.itemMap[handlerId]) {
            this.bindings[mediaType] = this.itemMap[handlerId].src;
        }
    }
    this.metadata = adapt.epub.readMetadata(pkg.child("metadata"), pkg.attribute("prefix")[0]);
    if (this.metadata[adapt.epub.metaTerms.language]) {
        this.lang = this.metadata[adapt.epub.metaTerms.language][0]["v"];
    }
    if (this.metadata[adapt.epub.metaTerms.layout]) {
        this.prePaginated = this.metadata[adapt.epub.metaTerms.layout][0]["v"] === "pre-paginated";
    }

    if (!zipMetadata) {
        if (idpfObfURLs.length > 0 && this.uid) {
            // Have to deobfuscate in JavaScript
            const deobfuscator = adapt.epub.makeDeobfuscator(this.uid);
            for (var i = 0; i < idpfObfURLs.length; i++) {
                this.store.deobfuscators[this.epubURL + idpfObfURLs[i]] = deobfuscator;
            }
        }
        if (this.prePaginated) {
            this.assignAutoPages();
        }
        return adapt.task.newResult(true);
    }
    const manifestText = new adapt.base.StringBuffer();
    const obfuscations = {};
    if (idpfObfURLs.length > 0 && this.uid) {
        // Deobfuscate in the server.
        const obfuscationKey = adapt.epub.makeObfuscationKey(this.uid);
        for (var i = 0; i < idpfObfURLs.length; i++) {
            obfuscations[idpfObfURLs[i]] = obfuscationKey;
        }
    }
    for (var i = 0; i < zipMetadata.length; i++) {
        const entry = zipMetadata[i];
        const encodedPath = entry["n"];
        if (encodedPath) {
            const path = decodeURI(encodedPath);
            var item = this.itemMapByPath[path];
            var mediaType = null;
            if (item) {
                item.compressed = entry["m"] != 0;
                item.compressedSize = entry["c"];
                if (item.mediaType) {
                    mediaType = item.mediaType.replace(/\s+/g, "");
                }
            }
            const obfuscation = obfuscations[path];
            if (mediaType || obfuscation) {
                manifestText.append(encodedPath);
                manifestText.append(' ');
                manifestText.append(mediaType || "application/octet-stream");
                if (obfuscation) {
                    manifestText.append(' ');
                    manifestText.append(obfuscation);
                }
                manifestText.append('\n');
            }
        }
    }
    self.assignAutoPages();
    return adapt.net.ajax(manifestURL, adapt.net.XMLHttpRequestResponseType.DEFAULT, "POST", manifestText.toString(), "text/plain");
};

/**
 * @return {void}
 */
adapt.epub.OPFDoc.prototype.assignAutoPages = function() {
    let epage = 0;

    for (const item of this.spine) {
        const epageCount = this.prePaginated ? 1 : Math.ceil(item.compressedSize / 1024);
        item.epage = epage;
        item.epageCount = epageCount;
        epage += epageCount;
    }

    this.epageCount = epage;

    if (this.epageCountCallback) {
        this.epageCountCallback(this.epageCount);
    }
};

/**
 * @param {boolean} epageIsRenderedPage
 * @param {?function(number)} epageCountCallback
 * @return {!adapt.task.Result.<boolean>}
 */
adapt.epub.OPFDoc.prototype.countPages = function(epageIsRenderedPage, epageCountCallback) {
    this.epageIsRenderedPage = epageIsRenderedPage || this.prePaginated;
    this.epageCountCallback = epageCountCallback;

    if (this.epageIsRenderedPage) {
        if (this.prePaginated && this.epageCount == 0) {
            this.assignAutoPages();
        }
        return adapt.task.newResult(true);
    }

    let epage = 0;
    let i = 0;
    /** @type {!adapt.task.Frame.<boolean>} */ const frame = adapt.task.newFrame("estimatePageCount");
    frame.loopWithFrame(loopFrame => {
        if (i === this.spine.length) {
            loopFrame.breakLoop();
            return;
        }
        const item = this.spine[i++];
        item.epage = epage;
        this.store.load(item.src).then(xmldoc => {
            // According to the old comment,
            // "Estimate that offset=2700 roughly corresponds to 1024 bytes of compressed size."
            // However, it should depend on the language.
            // Further adjustment needed.

            //let offsetPerEPage = 2700;
            let offsetPerEPage = 1800;
            const lang = xmldoc.lang || this.lang;
            if (lang && lang.match(/^(ja|ko|zh)/)) {
                offsetPerEPage /= 3;
            }
            item.epageCount = Math.ceil(xmldoc.getTotalOffset() / offsetPerEPage);
            epage += item.epageCount;
            this.epageCount = epage;
            if (this.epageCountCallback) {
                this.epageCountCallback(this.epageCount);
            }
            loopFrame.continueLoop();
        });
    }).thenFinish(frame);
    return frame.result();
};

/**
 * Creates a fake OPF "document" that contains OPS chapters.
 * @param {!Array<!adapt.epub.OPFItemParam>} params
 * @param {?Document} doc
 */
adapt.epub.OPFDoc.prototype.initWithChapters = function(params, doc) {
    this.itemMap = {};
    this.itemMapByPath = {};
    this.items = [];
    this.spine = this.items;
    // create a minimum fake OPF XML for navigation with EPUB CFI
    const opfXML = this.opfXML = new adapt.xmldoc.XMLDocHolder(null, "", new DOMParser().parseFromString("<spine></spine>", "text/xml"));

    params.forEach(function(param) {
        const item = new adapt.epub.OPFItem();
        item.initWithParam(param);
        goog.asserts.assert(item.id);

        const itemref = opfXML.document.createElement("itemref");
        itemref.setAttribute("idref", item.id);
        opfXML.root.appendChild(itemref);
        item.itemRefElement = itemref;

        this.itemMap[item.id] = item;
        this.itemMapByPath[param.url] = item;
        this.items.push(item);
    }, this);

    if (doc) {
        return this.store.addDocument(params[0].url, doc);
    } else {
        return adapt.task.newResult(null);
    }
};

/**
 * @param {number} spineIndex
 * @param {number} offsetInItem
 * @return {!adapt.task.Result.<?string>} cfi
 */
adapt.epub.OPFDoc.prototype.getCFI = function(spineIndex, offsetInItem) {
    const item = this.spine[spineIndex];
    /** @type {!adapt.task.Frame.<?string>} */ const frame = adapt.task.newFrame("getCFI");
    this.store.load(item.src).then(xmldoc => {
        const node = xmldoc.getNodeByOffset(offsetInItem);
        let cfi = null;
        if (node) {
            const startOffset = xmldoc.getNodeOffset(node, 0, false);
            const offsetInNode = offsetInItem - startOffset;
            const fragment = new adapt.cfi.Fragment();
            fragment.prependPathFromNode(node, offsetInNode, false, null);
            if (item.itemRefElement) {
                fragment.prependPathFromNode(item.itemRefElement, 0, false, null);
            }
            cfi = fragment.toString();
        }
        frame.finish(cfi);
    });
    return frame.result();
};

/**
 * @param {?string} fragstr
 * @return {!adapt.task.Result.<?adapt.epub.Position>}
 */
adapt.epub.OPFDoc.prototype.resolveFragment = function(fragstr) {
    const self = this;
    return adapt.task.handle("resolveFragment",
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @return {void}
         */
        frame => {
            if (!fragstr) {
                frame.finish(null);
                return;
            }
            let fragment = new adapt.cfi.Fragment();
            fragment.fromString(fragstr);
            let item;
            if (self.opfXML) {
                const opfNav = fragment.navigate(self.opfXML.document);
                if (opfNav.node.nodeType != 1 || opfNav.after || !opfNav.ref) {
                    frame.finish(null);
                    return;
                }
                const elem = /** @type {Element} */ (opfNav.node);
                const idref = elem.getAttribute("idref");
                if (elem.localName != "itemref" || !idref || !self.itemMap[idref]) {
                    frame.finish(null);
                    return;
                }
                item = self.itemMap[idref];
                fragment = opfNav.ref;
            } else {
                item = self.spine[0];
            }
            self.store.load(item.src).then(xmldoc => {
                const nodeNav = fragment.navigate(xmldoc.document);
                const offset = xmldoc.getNodeOffset(nodeNav.node, nodeNav.offset, nodeNav.after);
                frame.finish({spineIndex: item.spineIndex, offsetInItem: offset, pageIndex: -1});
            });
        },
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @param {Error} err
         * @return {void}
         */
        (frame, err) => {
            vivliostyle.logging.logger.warn(err, "Cannot resolve fragment:", fragstr);
            frame.finish(null);
        });
};

/**
 * @param {number} epage
 * @return {!adapt.task.Result.<?adapt.epub.Position>}
 */
adapt.epub.OPFDoc.prototype.resolveEPage = function(epage) {
    const self = this;
    return adapt.task.handle("resolveEPage",
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @return {void}
         */
        frame => {
            if (epage <= 0) {
                frame.finish({spineIndex: 0, offsetInItem: 0, pageIndex: -1});
                return;
            }
            if (self.epageIsRenderedPage) {
                let spineIndex = self.spine.findIndex(item => {
                    return item.epage == 0 && item.epageCount == 0 ||
                        item.epage <= epage && item.epage + item.epageCount > epage;
                });
                let item = self.spine[spineIndex];
                if (!item || item.epageCount == 0) {
                    item = self.spine[--spineIndex];
                }
                frame.finish({spineIndex, offsetInItem: -1, pageIndex: epage - item.epage});
                return;
            }
            let spineIndex = adapt.base.binarySearch(self.spine.length, index => {
                const item = self.spine[index];
                return item.epage + item.epageCount > epage;
            });
            if (spineIndex == self.spine.length) {
                spineIndex--;
            }
            const item = self.spine[spineIndex];
            self.store.load(item.src).then(xmldoc => {
                epage -= item.epage;
                if (epage > item.epageCount) {
                    epage = item.epageCount;
                }
                let offset = 0;
                if (epage > 0) {
                    const totalOffset = xmldoc.getTotalOffset();
                    offset = Math.round(totalOffset * epage / item.epageCount);
                    if (offset == totalOffset) {
                        offset--;
                    }
                }
                frame.finish({spineIndex, offsetInItem: offset, pageIndex: -1});
            });
        },
        /**
         * @param {!adapt.task.Frame.<?adapt.epub.Position>} frame
         * @param {Error} err
         * @return {void}
         */
        (frame, err) => {
            vivliostyle.logging.logger.warn(err, "Cannot resolve epage:", epage);
            frame.finish(null);
        });
};

/**
 * @param {adapt.epub.Position} position
 * @return {!adapt.task.Result.<number>}
 */
adapt.epub.OPFDoc.prototype.getEPageFromPosition = function(position) {
    const item = this.spine[position.spineIndex];
    if (this.epageIsRenderedPage) {
        const epage = item.epage + position.pageIndex;
        if (this.epageCount <= epage) {
            // This may happen when finishPageContainer() is called befre this.epageIsRenderedPage is set.
            this.epageCount = epage + 1;
        }
        return adapt.task.newResult(epage);
    }
    if (position.offsetInItem <= 0) {
        return adapt.task.newResult(item.epage);
    }
    /** @type {!adapt.task.Frame.<number>} */ const frame = adapt.task.newFrame("getEPage");
    this.store.load(item.src).then(xmldoc => {
        const totalOffset = xmldoc.getTotalOffset();
        const offset = Math.min(totalOffset, position.offsetInItem);
        frame.finish(item.epage + offset * item.epageCount / totalOffset);
    });
    return frame.result();
};

/**
 * @typedef {{page: !adapt.vtree.Page, position: !adapt.epub.Position}}
 */
adapt.epub.PageAndPosition;

/**
 * @param {!adapt.vtree.Page} page
 * @param {number} pageIndex
 * @returns {!adapt.epub.PageAndPosition}
 */
adapt.epub.makePageAndPosition = (page, pageIndex) => ({
    page,

    position: {
        spineIndex: page.spineIndex,
        pageIndex,
        offsetInItem: page.offset
    }
});

/**
 * @typedef {{
 * 		item: adapt.epub.OPFItem,
 * 		xmldoc: adapt.xmldoc.XMLDocHolder,
 * 		instance: adapt.ops.StyleInstance,
 * 		layoutPositions: Array.<adapt.vtree.LayoutPosition>,
 * 		pages: !Array.<adapt.vtree.Page>,
 * 		complete: boolean
 * }}
 */
adapt.epub.OPFViewItem;

/**
 * @constructor
 * @param {adapt.epub.OPFDoc} opf
 * @param {adapt.vgen.Viewport} viewport
 * @param {adapt.font.Mapper} fontMapper
 * @param {adapt.expr.Preferences} pref
 * @param {!function({width: number, height: number}, !Object<string, !{width: number, height: number}>, number, number)} pageSheetSizeReporter
 * @implements {adapt.vgen.CustomRendererFactory}
 */
adapt.epub.OPFView = function(opf, viewport, fontMapper, pref, pageSheetSizeReporter) {
    /** @const */ this.opf = opf;
    /** @const */ this.viewport = viewport;
    /** @const */ this.fontMapper = fontMapper;
    /** @const */ this.pageSheetSizeReporter = pageSheetSizeReporter;
    /** @type {Array.<adapt.epub.OPFViewItem>} */ this.spineItems = [];
    /** @const {Array.<Array.<adapt.task.Continuation>>} */ this.spineItemLoadingContinuations = [];
    /** @const */ this.pref = adapt.expr.clonePreferences(pref);
    /** @const */ this.clientLayout = new adapt.vgen.DefaultClientLayout(viewport);
    /** @const */ this.counterStore = new vivliostyle.counters.CounterStore(opf.documentURLTransformer);
};

/**
 * @private
 * @param {!adapt.epub.Position} position
 * @returns {adapt.vtree.Page}
 */
adapt.epub.OPFView.prototype.getPage = function(position) {
    const viewItem = this.spineItems[position.spineIndex];
    return viewItem ? viewItem.pages[position.pageIndex] : null;
};

/**
 * @param {adapt.epub.Position} position
 * @returns {?vivliostyle.constants.PageProgression}
 */
adapt.epub.OPFView.prototype.getCurrentPageProgression = function(position) {
    if (this.opf.pageProgression) {
        return this.opf.pageProgression;
    } else {
        const viewItem = this.spineItems[position ? position.spineIndex : 0];
        return viewItem ? viewItem.instance.pageProgression : null;
    }
};

/**
 * @private
 * @param {adapt.epub.OPFViewItem} viewItem
 * @param {adapt.vtree.Page} page
 * @param {number} pageIndex
 */
adapt.epub.OPFView.prototype.finishPageContainer = function(viewItem, page, pageIndex) {
    page.container.style.display = "none";
    page.container.style.visibility = "visible";
    page.container.style.position = "";
    page.container.style.top = "";
    page.container.style.left = "";
    page.container.setAttribute("data-vivliostyle-page-side", /** @type {string} */ (page.side));
    const oldPage = viewItem.pages[pageIndex];
    page.isFirstPage = viewItem.item.spineIndex == 0 && pageIndex == 0;
    viewItem.pages[pageIndex] = page;

    if (this.opf.epageIsRenderedPage) {
        if (pageIndex == 0 && viewItem.item.spineIndex > 0) {
            const prevItem = this.opf.spine[viewItem.item.spineIndex - 1];
            viewItem.item.epage = prevItem.epage + prevItem.epageCount;
        }
        viewItem.item.epageCount = viewItem.pages.length;
        this.opf.epageCount = this.opf.spine.reduce((count, item) => count + item.epageCount, 0);

        if (this.opf.epageCountCallback) {
            this.opf.epageCountCallback(this.opf.epageCount);
        }
    }

    if (oldPage) {
        viewItem.instance.viewport.contentContainer.replaceChild(page.container, oldPage.container);
        oldPage.dispatchEvent({
            type: "replaced",
            target: null,
            currentTarget: null,
            newPage: page
        });
    } else {
        // Find insert position in contentContainer.
        let insertPos = null;
        if (pageIndex > 0) {
            insertPos = viewItem.pages[pageIndex - 1].container.nextElementSibling;
        } else {
            for (let i = viewItem.item.spineIndex + 1; i < this.spineItems.length; i++) {
                const item = this.spineItems[i];
                if (item && item.pages[0]) {
                    insertPos = item.pages[0].container;
                    break;
                }
            }
        }
        viewItem.instance.viewport.contentContainer.insertBefore(page.container, insertPos);
    }
    this.pageSheetSizeReporter({ width: viewItem.instance.pageSheetWidth, height: viewItem.instance.pageSheetHeight },
                               viewItem.instance.pageSheetSize, viewItem.item.spineIndex, viewItem.instance.pageNumberOffset + pageIndex);
};

/**
 * @private
 * @typedef {{
 *      pageAndPosition: !adapt.epub.PageAndPosition,
 *      nextLayoutPosition: adapt.vtree.LayoutPosition
 * }}
 */
adapt.epub.OPFView.RenderSinglePageResult;

/**
 * Render a single page. If the new page contains elements with ids that are referenced from other pages by 'target-counter()', those pages are rendered too (calling `renderSinglePage` recursively).
 * @private
 * @param {!adapt.epub.OPFViewItem} viewItem
 * @param {adapt.vtree.LayoutPosition} pos
 * @returns {!adapt.task.Result<!adapt.epub.OPFView.RenderSinglePageResult>}
 */
adapt.epub.OPFView.prototype.renderSinglePage = function(viewItem, pos) {
    /** @type {!adapt.task.Frame<!adapt.epub.OPFView.RenderSinglePageResult>} */ const frame
        = adapt.task.newFrame("renderSinglePage");
    let page = this.makePage(viewItem, pos);
    const self = this;
    viewItem.instance.layoutNextPage(page, pos).then(posParam => {
        pos = /** @type {adapt.vtree.LayoutPosition} */ (posParam);
        const pageIndex = pos ? pos.page - 1 : viewItem.layoutPositions.length - 1;
        self.finishPageContainer(viewItem, page, pageIndex);

        self.counterStore.finishPage(page.spineIndex, pageIndex);

        // If the position of the page break change, we should re-layout the next page too.
        let cont = null;
        if (pos) {
            const prevPos = viewItem.layoutPositions[pos.page];
            viewItem.layoutPositions[pos.page] = pos;
            if (prevPos && viewItem.pages[pos.page]) {
                if (!pos.isSamePosition(prevPos)) {
                    cont = self.renderSinglePage(viewItem, pos);
                }
            }
        }
        if (!cont) {
            cont = adapt.task.newResult(true);
        }

        cont.then(() => {
            const unresolvedRefs = self.counterStore.getUnresolvedRefsToPage(page);
            let index = 0;
            frame.loopWithFrame(loopFrame => {
                index++;
                if (index > unresolvedRefs.length) {
                    loopFrame.breakLoop();
                    return;
                }
                const refs = unresolvedRefs[index - 1];
                refs.refs = refs.refs.filter(ref => !ref.isResolved());
                if (refs.refs.length === 0) {
                    loopFrame.continueLoop();
                    return;
                }

                self.getPageViewItem(refs.spineIndex).then(viewItem => {
                    if (!viewItem) {
                        loopFrame.continueLoop();
                        return;
                    }
                    self.counterStore.pushPageCounters(refs.pageCounters);
                    self.counterStore.pushReferencesToSolve(refs.refs);
                    const pos = viewItem.layoutPositions[refs.pageIndex];
                    self.renderSinglePage(viewItem, pos).then(result => {
                        self.counterStore.popPageCounters();
                        self.counterStore.popReferencesToSolve();
                        const resultPosition = result.pageAndPosition.position;
                        if (resultPosition.spineIndex === page.spineIndex && resultPosition.pageIndex === pageIndex) {
                            page = result.pageAndPosition.page;
                        }
                        loopFrame.continueLoop();
                    });
                });
            }).then(() => {
                if (!page.container.parentElement) {
                    // page is replaced
                    page = viewItem.pages[pageIndex];
                }
                page.isLastPage = !pos &&
                    (viewItem.item.spineIndex === self.opf.spine.length - 1);
                if (page.isLastPage) {
                    goog.asserts.assert(self.viewport);
                    self.counterStore.finishLastPage(self.viewport);
                }
                frame.finish({
                    pageAndPosition: adapt.epub.makePageAndPosition(page, pageIndex),
                    nextLayoutPosition: pos
{'fraction_non_alphanumeric': 0.14814814814814814, 'fraction_numerical': 0.004733167672464797, 'mean_word_length': 2.1716820197884683, 'pattern_counts': {'":': 32, '<': 119, '<?xml version=': 0, '>': 217, 'https://': 0, 'lorem ipsum': 0, 'www.': 12, 'xml': 80}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2710701', 'n_tokens_mistral': 29774, 'n_tokens_neox': 28652, 'n_words': 5243}