UNPKG

@internetarchive/bookreader

Version:
554 lines (499 loc) 17.5 kB
// @ts-check import { DEFAULT_OPTIONS } from './options.js'; import { clamp } from './utils.js'; /** @typedef {import('./options.js').PageData} PageData */ /** @typedef {import('../BookReader.js').default} BookReader */ /** * Contains information about the Book/Document independent of the way it is * being rendering. Nothing here should reference e.g. the mode, zoom, etc. * It's just information about the book and its pages (usually as specified * in the BookReader data option.) */ export class BookModel { /** * @param {BookReader} br */ constructor(br) { this.br = br; this.reduceSet = br.reduceSet; this.ppi = br.options?.ppi ?? DEFAULT_OPTIONS.ppi; /** @type {'lr' | 'rl'} Page progression */ this.pageProgression = br.options?.pageProgression ?? DEFAULT_OPTIONS.pageProgression; /** @type {{width: number, height: number}} memoize storage */ this._medianPageSize = null; /** @type {[PageData[], number]} */ this._getDataFlattenedCached = null; } /** Get median width/height of page in inches. Memoized for performance. */ getMedianPageSizeInches() { if (this._medianPageSize) { return this._medianPageSize; } const widths = []; const heights = []; for (const page of this.pagesIterator()) { widths.push(page.widthInches); heights.push(page.heightInches); } widths.sort((a, b) => a - b); heights.sort((a, b) => a - b); this._medianPageSize = { width: widths[Math.floor(widths.length / 2)], height: heights[Math.floor(heights.length / 2)], }; return this._medianPageSize; } /** * Returns the page width for the given index, or first or last page if out of range * @deprecated see getPageWidth * @param {PageIndex} index */ _getPageWidth(index) { // Synthesize a page width for pages not actually present in book. // May or may not be the best approach. // If index is out of range we return the width of first or last page index = clamp(index, 0, this.getNumLeafs() - 1); return this.getPageWidth(index); } /** * Returns the page height for the given index, or first or last page if out of range * @deprecated see getPageHeight * @param {PageIndex} index */ _getPageHeight(index) { const clampedIndex = clamp(index, 0, this.getNumLeafs() - 1); return this.getPageHeight(clampedIndex); } /** * Returns the *highest* index the given page number, or undefined * @param {PageNumString} pageNum * @return {PageIndex|undefined} */ getPageIndex(pageNum) { const pageIndices = this.getPageIndices(pageNum); return pageIndices.length ? pageIndices[pageIndices.length - 1] : undefined; } /** * Returns an array (possibly empty) of the indices with the given page number * @param {PageNumString} pageNum * @return {PageIndex[]} */ getPageIndices(pageNum) { const indices = []; // Check for special "nXX" page number if (pageNum.slice(0,1) == 'n') { try { const pageIntStr = pageNum.slice(1, pageNum.length); const pageIndex = parseInt(pageIntStr); indices.push(pageIndex); return indices; } catch (err) { // Do nothing... will run through page names and see if one matches } } for (let i = 0; i < this.getNumLeafs(); i++) { if (this.getPageNum(i) == pageNum) { indices.push(i); } } return indices; } /** * Returns the name of the page as it should be displayed in the user interface * @param {PageIndex} index * @return {string} */ getPageName(index) { return 'Page ' + this.getPageNum(index); } /** * @return {number} the total number of leafs (like an array length) */ getNumLeafs() { // For deprecated interface support, if numLeafs is set, use that. if (this.br.numLeafs !== undefined) return this.br.numLeafs; return this._getDataFlattened().length; } /** * @param {PageIndex} index * @return {Number|undefined} */ getPageWidth(index) { return this.getPageProp(index, 'width'); } /** * @param {PageIndex} index * @return {Number|undefined} */ getPageHeight(index) { return this.getPageProp(index, 'height'); } /** * @param {PageIndex} index * @param {number} reduce - not used in default implementation * @param {number} rotate - not used in default implementation * @return {string|undefined} */ // eslint-disable-next-line no-unused-vars getPageURI(index, reduce, rotate) { if (!this.getPageProp(index, 'viewable', true)) { const uri = this.br.options.unviewablePageURI; if (uri.startsWith('.')) { // It's a relative path, so make it relative to the images path return this.br.options.imagesBaseURL + uri; } else { return uri; } } else { return this.getPageProp(index, 'uri'); } } /** * @param {PageIndex} index * @return {'L' | 'R'} */ getPageSide(index) { return this.getPageProp(index, 'pageSide') || (index % 2 === 0 ? 'R' : 'L'); } /** * @param {PageIndex} index * @return {PageNumString} */ getPageNum(index) { const pageNum = this.getPageProp(index, 'pageNum'); return pageNum === undefined ? `n${index}` : pageNum; } /** * Generalized property accessor. * @param {PageIndex} index * @param {keyof PageData} propName * @param {*} [fallbackValue] return if undefined * @return {*|undefined} */ getPageProp(index, propName, fallbackValue = undefined) { return this._getDataProp(index, propName, fallbackValue); } /** * This function returns the left and right indices for the user-visible * spread that contains the given index. * @note Can return indices out of range of what's in the book. * @param {PageIndex} pindex * @return {[PageIndex, PageIndex]} eg [0, 1] */ getSpreadIndices(pindex) { if (this.pageProgression == 'rl') { return this.getPageSide(pindex) == 'R' ? [pindex + 1, pindex] : [pindex, pindex - 1]; } else { return this.getPageSide(pindex) == 'L' ? [pindex, pindex + 1] : [pindex - 1, pindex]; } } /** * Single images in the Internet Archive scandata.xml metadata are (somewhat incorrectly) * given a "leaf" number. Some of these images from the scanning process should not * be displayed in the BookReader (for example colour calibration cards). Since some * of the scanned images will not be displayed in the BookReader (those marked with * addToAccessFormats false in the scandata.xml) leaf numbers and BookReader page * indexes are generally not the same. This function returns the BookReader page * index given a scanned leaf number. * * This function is used, for example, to map between search results (that use the * leaf numbers) and the displayed pages in the BookReader. * @param {LeafNum} leafNum * @return {PageIndex} */ leafNumToIndex(leafNum) { const index = this._getDataFlattened() .findIndex(d => d.leafNum == leafNum); // If no match is found, fall back to the leafNum provide (leafNum == index) return index > -1 ? index : leafNum; } /** * Parses the pageString format * @param {PageString} pageString * @return {PageIndex|undefined} */ parsePageString(pageString) { let pageIndex; // Check for special "leaf" const leafMatch = /^leaf(\d+)/.exec(pageString); if (leafMatch) { pageIndex = this.leafNumToIndex(parseInt(leafMatch[1], 10)); if (pageIndex === null) { pageIndex = undefined; // to match return type of getPageIndex } } else { pageIndex = this.getPageIndex(pageString); } return pageIndex; } /** * @param {number} index use negatives to get page relative to end * @param loop whether to loop (i.e. -1 == last page) */ getPage(index, loop = true) { const numLeafs = this.getNumLeafs(); if (!loop && (index < 0 || index >= numLeafs)) { return undefined; } if (index < 0 && index >= -numLeafs) { index += numLeafs; } index = index % numLeafs; return new PageModel(this, index); } /** * @param {object} [arg0] * @param {number} [arg0.start] inclusive * @param {number} [arg0.end] exclusive * @param {boolean} [arg0.combineConsecutiveUnviewables] Yield only first unviewable * of a chunk of unviewable pages instead of each page */ * pagesIterator({ start = 0, end = Infinity, combineConsecutiveUnviewables = false } = {}) { start = Math.max(0, start); end = Math.min(end, this.getNumLeafs()); for (let i = start; i < end; i++) { const page = this.getPage(i); if (combineConsecutiveUnviewables && page.isConsecutiveUnviewable) continue; yield page; } } /** * Flatten the nested structure (make 1d array), and also add pageSide prop * @return {PageData[]} */ _getDataFlattened() { if (this._getDataFlattenedCached && this._getDataFlattenedCached[1] === this.br.data.length) return this._getDataFlattenedCached[0]; let prevPageSide = null; /** @type {number|null} */ let unviewablesChunkStart = null; let index = 0; // @ts-ignore TS doesn't know about flatMap for some reason const flattened = this.br.data.flatMap(spread => { return spread.map(page => { if (!page.pageSide) { if (prevPageSide === null) { page.pageSide = spread.length === 2 ? 'L' : 'R'; } else { page.pageSide = prevPageSide === 'L' ? 'R' : 'L'; } } prevPageSide = page.pageSide; if (page.viewable === false) { if (unviewablesChunkStart === null) { page.unviewablesStart = unviewablesChunkStart = index; } else { page.unviewablesStart = unviewablesChunkStart; } } else { unviewablesChunkStart = null; } index++; return page; }); }); // length is used as a cache breaker this._getDataFlattenedCached = [flattened, this.br.data.length]; return flattened; } /** * Helper. Return a prop for a given index. Returns `fallbackValue` if index is invalid or * property not on page. * @param {PageIndex} index * @param {keyof PageData} prop * @param {*} fallbackValue return if property not on the record * @return {*} */ _getDataProp(index, prop, fallbackValue = undefined) { const dataf = this._getDataFlattened(); const invalidIndex = isNaN(index) || index < 0 || index >= dataf.length; if (invalidIndex || 'undefined' == typeof(dataf[index][prop])) return fallbackValue; return dataf[index][prop]; } } /** * A controlled schema for page data. */ export class PageModel { /** * @param {BookModel} book * @param {PageIndex} index */ constructor(book, index) { // Values less than 10 cause the UI to not work correctly const pagePPI = book._getDataProp(index, 'ppi', book.ppi); this.ppi = Math.max(pagePPI < 10 ? book.ppi : pagePPI, 10); this.book = book; this.index = index; this.width = book.getPageWidth(index); this.widthInches = this.width / this.ppi; this.height = book.getPageHeight(index); this.heightInches = this.height / this.ppi; this.pageSide = book.getPageSide(index); this.leafNum = book._getDataProp(index, 'leafNum', this.index); /** @type {boolean} */ this.isViewable = book._getDataProp(index, 'viewable', true); /** @type {PageIndex} The first in the series of unviewable pages this is in. */ this.unviewablesStart = book._getDataProp(index, 'unviewablesStart') || null; /** * Consecutive unviewable pages are pages in an unviewable "chunk" which are not the first * of that chunk. */ this.isConsecutiveUnviewable = !this.isViewable && this.unviewablesStart != this.index; this._rawData = this.book._getDataFlattened()[this.index]; } /** * Updates the page to no longer be unviewable. Assumes the * Page's URI is already set/correct. */ makeViewable(newViewableState = true) { if (this.isViewable == newViewableState) return; if (newViewableState) { this._rawData.viewable = true; delete this._rawData.unviewablesStart; // Update any subsequent page to now point to the right "start" for (const page of this.book.pagesIterator({ start: this.index + 1 })) { if (page.isViewable) break; page._rawData.unviewablesStart = this.index + 1; } } else { this._rawData.viewable = false; this._rawData.unviewablesStart = (this.prev && !this.prev.isViewable) ? this.prev.unviewablesStart : this.index; // Update any subsequent page to now point to the right "start" for (const page of this.book.pagesIterator({ start: this.index + 1 })) { if (!page.isViewable) break; page._rawData.unviewablesStart = this._rawData.unviewablesStart; } } } get prev() { return this.findPrev(); } get next() { return this.findNext(); } /** @type {PageModel | null} */ get left() { return this.book.pageProgression === 'lr' ? this.prev : this.next; } /** @type {PageModel | null} */ get right() { return this.book.pageProgression === 'lr' ? this.next : this.prev; } /** * @type {{left: PageModel | null, right: PageModel | null}} */ get spread() { return { left: this.pageSide === 'L' ? this : this.left, right: this.pageSide === 'R' ? this : this.right, }; } /** * @param {number} pages */ goLeft(pages) { const newIndex = this.book.pageProgression === 'lr' ? this.index - pages : this.index + pages; return this.book.getPage(newIndex); } /** * @param {number} pages */ goRight(pages) { const newIndex = this.book.pageProgression === 'lr' ? this.index + pages : this.index - pages; return this.book.getPage(newIndex); } /** * @param {number} reduce * @param {number} rotate */ getURI(reduce, rotate) { return this.book.getPageURI(this.index, reduce, rotate); } /** * Returns the srcset with correct URIs or void string if out of range * @param {number} reduce * @param {number} [rotate] */ getURISrcSet(reduce, rotate = 0) { const { reduceSet } = this.book; const initialReduce = reduceSet.floor(reduce); // We don't need to repeat the initial reduce in the srcset const topReduce = reduceSet.decr(initialReduce); const reduces = []; for (let r = topReduce; r >= 1; r = reduceSet.decr(r)) { reduces.push(r); } return reduces .map(r => `${this.getURI(r, rotate)} ${initialReduce / r}x`) .join(', '); } /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page * of a series of unviewable pages instead of each page * @return {PageModel|void} */ findNext({ combineConsecutiveUnviewables = false } = {}) { return this.book .pagesIterator({ start: this.index + 1, combineConsecutiveUnviewables }) .next().value; } /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page * of a series of unviewable pages instead of each page * @return {PageModel|void} */ findPrev({ combineConsecutiveUnviewables = false } = {}) { if (this.index == 0) return undefined; if (combineConsecutiveUnviewables) { if (this.isConsecutiveUnviewable) { return this.book.getPage(this.unviewablesStart); } else { // Recursively goes backward through the book // TODO make a reverse iterator to make it look identical to findNext const prev = new PageModel(this.book, this.index - 1); return prev.isViewable ? prev : prev.findPrev({ combineConsecutiveUnviewables }); } } else { return new PageModel(this.book, this.index - 1); } } /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page * of a series of unviewable pages instead of each page * @return {PageModel|void} */ findLeft({ combineConsecutiveUnviewables = false } = {}) { return this.book.pageProgression === 'lr' ? this.findPrev({ combineConsecutiveUnviewables }) : this.findNext({ combineConsecutiveUnviewables }); } /** * @param {object} [arg0] * @param {boolean} [arg0.combineConsecutiveUnviewables] Whether to only yield the first page * of a series of unviewable pages instead of each page * @return {PageModel|void} */ findRight({ combineConsecutiveUnviewables = false } = {}) { return this.book.pageProgression === 'lr' ? this.findNext({ combineConsecutiveUnviewables }) : this.findPrev({ combineConsecutiveUnviewables }); } } // There are a few main ways we can reference a specific page in a book: /** * @typedef {string} PageNumString * Possible values: /^n?\d+$/. Example: 'n7', '18' * Not necessarily unique */ /** * @typedef {number} LeafNum * No clue if 0 or 1 indexed or consecutive; generally from IA book info. */ /** * @typedef {string} PageString * Possible values: /^(leaf)?\d+$/ Example: 'leaf7', '18' * If leaf-prefixed, then the number is a LeafNum. Otherwise it's a PageNumString */ /** @typedef {number} PageIndex 0-based index of all the pages */