Add sample reader

This commit is contained in:
John Factotum authored and GitHub committed 2022-10-19 11:04:43 +00:00
1 parent 1d1b5ed82b
commit d0cd085a22
5 files changed
+488

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; script-src 'self'; style-src 'self' blob: 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' blob: data:; frame-src blob: data:; object-src 'none'; form-action 'none';">
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
}
#drop-target {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#nav-bar {
visibility: hidden;
position: absolute;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 48px;
padding: 6px;
opacity: .25;
transition: opacity 250ms ease;
}
#nav-bar:hover {
opacity: 1;
}
#nav-bar button {
padding: 3px;
border-radius: 6px;
background: none;
border: 0;
}
#nav-bar button:hover {
background: rgba(0, 0, 0, .1);
}
#progress-label {
font-size: small;
font-variant-numeric: tabular-nums;
}
.icon {
display: block;
fill: none;
stroke: currentcolor;
stroke-width: 2px;
}
</style>
<input type="file" id="file-input" hidden>
<div id="drop-target" class="filter">Drop a file here to open it.</div>
<div id="nav-bar">
<button id="left-button" aria-label="Turn left page">
<svg class="icon" width="24" height="24" aria-hidden="true">
<path d="M 15 6 L 9 12 L 15 18"/>
</svg>
</button>
<div id="progress-label"></div>
<button id="right-button" aria-label="Turn right page">
<svg class="icon" width="24" height="24" aria-hidden="true">
<path d="M 9 6 L 15 12 L 9 18"/>
</svg>
</button>
</div>
<script src="vendor/zip-no-worker.min.js"></script>
<script src="vendor/fflate.min.js"></script>
<script src="reader.js" type="module"></script>
+145
View File
@@ -0,0 +1,145 @@
/* global zip: false, fflate: false */
import { View } from './view.js'
const { ZipReader, BlobReader, TextWriter, BlobWriter } = zip
zip.configure({ useWebWorkers: false })
const isZip = async file => {
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
}
const makeZipLoader = async file => {
const reader = new ZipReader(new BlobReader(file))
const entries = await reader.getEntries()
const map = new Map(entries.map(entry => [entry.filename, entry]))
const load = f => (name, ...args) =>
map.has(name) ? f(map.get(name), ...args) : null
const loadText = load(entry => entry.getData(new TextWriter()))
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
const getSize = name => map.get(name)?.uncompressedSize ?? 0
return { entries, loadText, loadBlob, getSize }
}
const isCBZ = ({ name, type }) =>
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
const isFB2 = ({ name, type }) =>
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
const isFBZ = ({ name, type }) =>
type === 'application/x-zip-compressed-fb2'
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
globalThis.open = async (file, emit) => {
if (!file.size) throw new Error('File not found')
let book
if (await isZip(file)) {
const loader = await makeZipLoader(file)
if (isCBZ(file)) {
const { makeComicBook } = await import('./comic-book.js')
book = makeComicBook(loader, file)
} else if (isFBZ(file)) {
const { makeFB2 } = await import('./fb2.js')
const { entries } = loader
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
book = await makeFB2(blob)
} else {
const { EPUB } = await import('./epub.js')
book = await new EPUB(loader).init()
}
} else {
const { isMOBI, MOBI } = await import('./mobi.js')
if (await isMOBI(file))
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
else if (isFB2(file)) {
const { makeFB2 } = await import('./fb2.js')
book = await makeFB2(file)
}
}
if (!book) throw new Error('File type not supported')
const view = new View(book, emit)
const element = await view.display()
document.body.append(element)
return view
}
const getCSS = ({ spacing, justify, hyphenate }) => `
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
color: lightblue;
}
}
p, li, blockquote, dd {
line-height: ${spacing};
text-align: ${justify ? 'justify' : 'start'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2;
hanging-punctuation: allow-end last;
widows: 2;
}
pre {
white-space: pre-wrap !important;
}
aside[epub|type~="endnote"],
aside[epub|type~="footnote"],
aside[epub|type~="note"],
aside[epub|type~="rearnote"] {
display: none;
}
`
const $ = document.querySelector.bind(document)
const locales = 'en'
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
const emit = obj => {
console.debug(obj)
switch (obj.type) {
case 'relocated': {
const { fraction, location, pageItem } = obj
const percent = percentFormat.format(fraction)
const loc = pageItem
? `Page ${pageItem.label}`
: `Loc ${location.current}`
$('#progress-label').innerText = `${percent} · ${loc}`
break
}
}
}
const dragOverHandler = e => e.preventDefault()
const dropHandler = e => {
e.preventDefault()
const file = Array.from(e.dataTransfer.items)
.find(item => item.kind === 'file')?.getAsFile()
if (file) {
document.body.removeChild($('#drop-target'))
open(file, emit)
.then(view => {
const style = {
spacing: 1.4,
justify: true,
hyphenate: true,
}
view.setAppearance({ css: getCSS(style) })
view.renderer.next()
$('#nav-bar').style.visibility = 'visible'
$('#left-button').addEventListener('click', () => view.goLeft())
$('#right-button').addEventListener('click', () => view.goRight())
})
.catch(e => emit(e))
}
}
const dropTarget = $('#drop-target')
dropTarget.addEventListener('drop', dropHandler)
dropTarget.addEventListener('dragover', dragOverHandler)
+1
View File
@@ -0,0 +1 @@
!function(f){typeof module!='undefined'&&typeof exports=='object'?module.exports=f():typeof define!='undefined'&&define.amd?define(f):(typeof self!='undefined'?self:this).fflate=f()}(function(){var _e={};"use strict";var t=(typeof module!='undefined'&&typeof exports=='object'?function(_f){"use strict";var e,t=";var __w=require('worker_threads');__w.parentPort.on('message',function(m){onmessage({data:m})}),postMessage=function(m,t){__w.parentPort.postMessage(m,t)},close=process.exit;self=global";try{e=require("worker_threads").Worker}catch(e){}exports.default=e?function(r,n,o,a,s){var u=!1,i=new e(r+t,{eval:!0}).on("error",(function(e){return s(e,null)})).on("message",(function(e){return s(null,e)})).on("exit",(function(e){e&&!u&&s(Error("exited with code "+e),null)}));return i.postMessage(o,a),i.terminate=function(){return u=!0,e.prototype.terminate.call(i)},i}:function(e,t,r,n,o){setImmediate((function(){return o(Error("async operations unsupported - update to Node 12+ (or Node 10-11 with the --experimental-worker CLI flag)"),null)}));var a=function(){};return{terminate:a,postMessage:a}};return _f}:function(_f){"use strict";var e={};_f.default=function(r,t,s,a,n){var o=new Worker(e[t]||(e[t]=URL.createObjectURL(new Blob([r+';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})'],{type:"text/javascript"}))));return o.onmessage=function(e){var r=e.data,t=r.$e$;if(t){var s=Error(t[0]);s.code=t[1],s.stack=t[2],n(s,null)}else n(null,r)},o.postMessage(s,a),o};return _f})({}),n=Uint8Array,r=Uint16Array,e=Uint32Array,i=new n([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),o=new n([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),a=new n([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),s=function(t,n){for(var i=new r(31),o=0;o<31;++o)i[o]=n+=1<<t[o-1];var a=new e(i[30]);for(o=1;o<30;++o)for(var s=i[o];s<i[o+1];++s)a[s]=s-i[o]<<5|o;return[i,a]},u=s(i,2),f=u[0],h=u[1];f[28]=258,h[258]=28;for(var c=s(o,0),l=c[0],p=c[1],v=new r(32768),d=0;d<32768;++d){var g=(43690&d)>>>1|(21845&d)<<1;v[d]=((65280&(g=(61680&(g=(52428&g)>>>2|(13107&g)<<2))>>>4|(3855&g)<<4))>>>8|(255&g)<<8)>>>1}var y=function(t,n,e){for(var i=t.length,o=0,a=new r(n);o<i;++o)t[o]&&++a[t[o]-1];var s,u=new r(n);for(o=0;o<n;++o)u[o]=u[o-1]+a[o-1]<<1;if(e){s=new r(1<<n);var f=15-n;for(o=0;o<i;++o)if(t[o])for(var h=o<<4|t[o],c=n-t[o],l=u[t[o]-1]++<<c,p=l|(1<<c)-1;l<=p;++l)s[v[l]>>>f]=h}else for(s=new r(i),o=0;o<i;++o)t[o]&&(s[o]=v[u[t[o]-1]++]>>>15-t[o]);return s},m=new n(288);for(d=0;d<144;++d)m[d]=8;for(d=144;d<256;++d)m[d]=9;for(d=256;d<280;++d)m[d]=7;for(d=280;d<288;++d)m[d]=8;var w=new n(32);for(d=0;d<32;++d)w[d]=5;var x=y(m,9,0),b=y(m,9,1),z=y(w,5,0),k=y(w,5,1),M=function(t){for(var n=t[0],r=1;r<t.length;++r)t[r]>n&&(n=t[r]);return n},S=function(t,n,r){var e=n/8|0;return(t[e]|t[e+1]<<8)>>(7&n)&r},T=function(t,n){var r=n/8|0;return(t[r]|t[r+1]<<8|t[r+2]<<16)>>(7&n)},A=function(t){return(t+7)/8|0},E=function(t,i,o){(null==i||i<0)&&(i=0),(null==o||o>t.length)&&(o=t.length);var a=new(2==t.BYTES_PER_ELEMENT?r:4==t.BYTES_PER_ELEMENT?e:n)(o-i);return a.set(t.subarray(i,o)),a};_e.FlateErrorCode={UnexpectedEOF:0,InvalidBlockType:1,InvalidLengthLiteral:2,InvalidDistance:3,StreamFinished:4,NoStreamHandler:5,InvalidHeader:6,NoCallback:7,InvalidUTF8:8,ExtraFieldTooLong:9,InvalidDate:10,FilenameTooLong:11,StreamFinishing:12,InvalidZipData:13,UnknownCompressionMethod:14};var D=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],C=function(t,n,r){var e=Error(n||D[t]);if(e.code=t,Error.captureStackTrace&&Error.captureStackTrace(e,C),!r)throw e;return e},U=function(t,r,e){var s=t.length;if(!s||e&&e.f&&!e.l)return r||new n(0);var u=!r||e,h=!e||e.i;e||(e={}),r||(r=new n(3*s));var c=function(t){var e=r.length;if(t>e){var i=new n(Math.max(2*e,t));i.set(r),r=i}},p=e.f||0,v=e.p||0,d=e.b||0,g=e.l,m=e.d,w=e.m,x=e.n,z=8*s;do{if(!g){p=S(t,v,1);var D=S(t,v+1,3);if(v+=3,!D){var U=t[(B=A(v)+4)-4]|t[B-3]<<8,I=B+U;if(I>s){h&&C(0);break}u&&c(d+U),r.set(t.subarray(B,I),d),e.b=d+=U,e.p=v=8*I,e.f=p;continue}if(1==D)g=b,m=k,w=9,x=5;else if(2==D){var F=S(t,v,31)+257,Z=S(t,v+10,15)+4,O=F+S(t,v+5,31)+1;v+=14;for(var G=new n(O),L=new n(19),N=0;N<Z;++N)L[a[N]]=S(t,v+3*N,7);v+=3*Z;var P=M(L),_=(1<<P)-1,q=y(L,P,1);for(N=0;N<O;){var B,H=q[S(t,v,_)];if(v+=15&H,(B=H>>>4)<16)G[N++]=B;else{var Y=0,j=0;for(16==B?(j=3+S(t,v,3),v+=2,Y=G[N-1]):17==B?(j=3+S(t,v,7),v+=3):18==B&&(j=11+S(t,v,127),v+=7);j--;)G[N++]=Y}}var R=G.subarray(0,F),J=G.subarray(F);w=M(R),x=M(J),g=y(R,w,1),m=y(J,x,1)}else C(1);if(v>z){h&&C(0);break}}u&&c(d+131072);for(var K=(1<<w)-1,Q=(1<<x)-1,V=v;;V=v){var W=(Y=g[T(t,v)&K])>>>4;if((v+=15&Y)>z){h&&C(0);break}if(Y||C(2),W<256)r[d++]=W;else{if(256==W){V=v,g=null;break}var X=W-254;W>264&&(X=S(Line truncated
+1
View File
@@ -0,0 +1 @@
((e,t)=>{"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).zip={})})(this,(function(e){"use strict";const{Array:t,Object:n,String:i,BigInt:r,Math:a,Date:s,Map:o,URL:l,Error:c,Uint8Array:d,Uint16Array:f,Uint32Array:u,DataView:h,Blob:_,Promise:w,TextEncoder:p,TextDecoder:g,FileReader:b,document:x,crypto:y,btoa:m}=globalThis,k=-2;function v(e){return R(e.map((([e,n])=>new t(e).fill(n,0,e))))}function R(e){return e.reduce(((e,n)=>e.concat(t.isArray(n)?R(n):n)),[])}const E=[0,1,2,3].concat(...v([[2,4],[2,5],[4,6],[4,7],[8,8],[8,9],[16,10],[16,11],[32,12],[32,13],[64,14],[64,15],[2,0],[1,16],[1,17],[2,18],[2,19],[4,20],[4,21],[8,22],[8,23],[16,24],[16,25],[32,26],[32,27],[64,28],[64,29]]));function D(){const e=this;function t(e,t){let n=0;do{n|=1&e,e>>>=1,n<<=1}while(--t>0);return n>>>1}e.build_tree=n=>{const i=e.dyn_tree,r=e.stat_desc.static_tree,s=e.stat_desc.elems;let o,l,c,d=-1;for(n.heap_len=0,n.heap_max=573,o=0;s>o;o++)0!==i[2*o]?(n.heap[++n.heap_len]=d=o,n.depth[o]=0):i[2*o+1]=0;for(;2>n.heap_len;)c=n.heap[++n.heap_len]=2>d?++d:0,i[2*c]=1,n.depth[c]=0,n.opt_len--,r&&(n.static_len-=r[2*c+1]);for(e.max_code=d,o=a.floor(n.heap_len/2);o>=1;o--)n.pqdownheap(i,o);c=s;do{o=n.heap[1],n.heap[1]=n.heap[n.heap_len--],n.pqdownheap(i,1),l=n.heap[1],n.heap[--n.heap_max]=o,n.heap[--n.heap_max]=l,i[2*c]=i[2*o]+i[2*l],n.depth[c]=a.max(n.depth[o],n.depth[l])+1,i[2*o+1]=i[2*l+1]=c,n.heap[1]=c++,n.pqdownheap(i,1)}while(n.heap_len>=2);n.heap[--n.heap_max]=n.heap[1],(t=>{const n=e.dyn_tree,i=e.stat_desc.static_tree,r=e.stat_desc.extra_bits,a=e.stat_desc.extra_base,s=e.stat_desc.max_length;let o,l,c,d,f,u,h=0;for(d=0;15>=d;d++)t.bl_count[d]=0;for(n[2*t.heap[t.heap_max]+1]=0,o=t.heap_max+1;573>o;o++)l=t.heap[o],d=n[2*n[2*l+1]+1]+1,d>s&&(d=s,h++),n[2*l+1]=d,l>e.max_code||(t.bl_count[d]++,f=0,a>l||(f=r[l-a]),u=n[2*l],t.opt_len+=u*(d+f),i&&(t.static_len+=u*(i[2*l+1]+f)));if(0!==h){do{for(d=s-1;0===t.bl_count[d];)d--;t.bl_count[d]--,t.bl_count[d+1]+=2,t.bl_count[s]--,h-=2}while(h>0);for(d=s;0!==d;d--)for(l=t.bl_count[d];0!==l;)c=t.heap[--o],c>e.max_code||(n[2*c+1]!=d&&(t.opt_len+=(d-n[2*c+1])*n[2*c],n[2*c+1]=d),l--)}})(n),((e,n,i)=>{const r=[];let a,s,o,l=0;for(a=1;15>=a;a++)r[a]=l=l+i[a-1]<<1;for(s=0;n>=s;s++)o=e[2*s+1],0!==o&&(e[2*s]=t(r[o]++,o))})(i,e.max_code,n.bl_count)}}function A(e,t,n,i,r){const a=this;a.static_tree=e,a.extra_bits=t,a.extra_base=n,a.elems=i,a.max_length=r}D._length_code=[0,1,2,3,4,5,6,7].concat(...v([[2,8],[2,9],[2,10],[2,11],[4,12],[4,13],[4,14],[4,15],[8,16],[8,17],[8,18],[8,19],[16,20],[16,21],[16,22],[16,23],[32,24],[32,25],[32,26],[31,27],[1,28]])),D.base_length=[0,1,2,3,4,5,6,7,8,10,12,14,16,20,24,28,32,40,48,56,64,80,96,112,128,160,192,224,0],D.base_dist=[0,1,2,3,4,6,8,12,16,24,32,48,64,96,128,192,256,384,512,768,1024,1536,2048,3072,4096,6144,8192,12288,16384,24576],D.d_code=e=>256>e?E[e]:E[256+(e>>>7)],D.extra_lbits=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],D.extra_dbits=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],D.extra_blbits=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],D.bl_order=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];const F=v([[144,8],[112,9],[24,7],[8,8]]);A.static_ltree=R([12,140,76,204,44,172,108,236,28,156,92,220,60,188,124,252,2,130,66,194,34,162,98,226,18,146,82,210,50,178,114,242,10,138,74,202,42,170,106,234,26,154,90,218,58,186,122,250,6,134,70,198,38,166,102,230,22,150,86,214,54,182,118,246,14,142,78,206,46,174,110,238,30,158,94,222,62,190,126,254,1,129,65,193,33,161,97,225,17,145,81,209,49,177,113,241,9,137,73,201,41,169,105,233,25,153,89,217,57,185,121,249,5,133,69,197,37,165,101,229,21,149,85,213,53,181,117,245,13,141,77,205,45,173,109,237,29,157,93,221,61,189,125,253,19,275,147,403,83,339,211,467,51,307,179,435,115,371,243,499,11,267,139,395,75,331,203,459,43,299,171,427,107,363,235,491,27,283,155,411,91,347,219,475,59,315,187,443,123,379,251,507,7,263,135,391,71,327,199,455,39,295,167,423,103,359,231,487,23,279,151,407,87,343,215,471,55,311,183,439,119,375,247,503,15,271,143,399,79,335,207,463,47,303,175,431,111,367,239,495,31,287,159,415,95,351,223,479,63,319,191,447,127,383,255,511,0,64,32,96,16,80,48,112,8,72,40,104,24,88,56,120,4,68,36,100,20,84,52,116,3,131,67,195,35,163,99,227].map(((e,t)=>[e,F[t]])));const S=v([[30,5]]);function z(e,t,n,i,r){const a=this;a.good_length=e,a.max_lazy=t,a.nice_length=n,a.max_chain=i,a.func=r}A.static_dtree=R([0,16,8,24,4,20,12,28,2,18,10,26,6,22,14,30,1,17,9,25,5,21,13,29,3,19,11,27,7,23].map(((e,t)=>[e,S[t]]))),A.static_l_desc=new A(A.static_ltree,D.extra_lbits,257,286,15),A.static_d_desc=new A(A.static_dtree,D.extra_dbits,0,30,15),A.static_bl_desc=new A(null,D.extra_blbits,0,19,7);const T=[new z(0,0,0,0,0),new z(4,4,8,4,1),new z(4,5,16,8,1),new z(4,6,32,32,1),new z(4,4,16,16,2),new z(8,16,32,32,2),new z(8,16,128,128,2),new z(8,32,128,256,2),new z(32,128,258,1024,2),nLine truncated
+267
View File
@@ -0,0 +1,267 @@
import * as CFI from './epubcfi.js'
import { TOCProgress, SectionProgress } from './progress.js'
const textWalker = function* (doc, func) {
const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
| NodeFilter.SHOW_CDATA_SECTION
const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter
const acceptNode = node => {
const name = node.localName?.toLowerCase()
if (name === 'script' || name === 'style') return FILTER_REJECT
if (node.nodeType === 1) return FILTER_SKIP
return FILTER_ACCEPT
}
const walker = doc.createTreeWalker(doc.body, filter, { acceptNode })
const nodes = []
for (let node = walker.nextNode(); node; node = walker.nextNode())
nodes.push(node)
const strs = nodes.map(node => node.nodeValue)
const makeRange = (startIndex, startOffset, endIndex, endOffset) => {
const range = doc.createRange()
range.setStart(nodes[startIndex], startOffset)
range.setEnd(nodes[endIndex], endOffset)
return range
}
for (const match of func(strs, makeRange)) yield match
}
const frameRect = (frame, rect) => {
const left = rect.left + frame.left
const right = rect.right + frame.left
const top = rect.top + frame.top
const bottom = rect.bottom + frame.top
return { left, right, top, bottom }
}
const pointIsInView = ({ x, y }) =>
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight
export const getPosition = target => {
// TODO: vertical text
const frameElement = (target.getRootNode?.() ?? target?.endContainer?.getRootNode?.())
?.defaultView?.frameElement
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 }
const rects = Array.from(target.getClientRects())
const first = frameRect(frame, rects[0])
const last = frameRect(frame, rects.at(-1))
const start = {
point: { x: (first.left + first.right) / 2, y: first.top },
dir: 'up',
}
const end = {
point: { x: (last.left + last.right) / 2, y: last.bottom },
dir: 'down',
}
const startInView = pointIsInView(start.point)
const endInView = pointIsInView(end.point)
if (!startInView && !endInView) return { point: { x: 0, y: 0 } }
if (!startInView) return end
if (!endInView) return start
return start.point.y > window.innerHeight - end.point.y ? start : end
}
// https://www.w3.org/TR/epub-ssv-11/
const SSV = Object.fromEntries(Array.from(Object.entries({
isRef: ['annoref', 'biblioref', 'glossref', 'noteref'],
isLink: ['backlink'],
isNote: ['annotation', 'note', 'footnote', 'endnote', 'rearnote'],
}), ([k, v]) => [k, el =>
el.getAttributeNS('http://www.idpf.org/2007/ops', 'type')
?.split(/s/)?.some(t => v.includes(t))]))
export class View {
#sectionProgress
#tocProgress
#pageProgress
language = 'en'
textDirection = ''
isCJK = false
isFixedLayout = false
#css
constructor(book, emit) {
this.book = book
this.emit = emit
if (book.metadata?.language) try {
const language = book.metadata.language
book.metadata.language = Intl.getCanonicalLocales(language)[0]
const tag = typeof language === 'string' ? language : language[0]
const locale = new Intl.Locale(tag)
this.isCJK = ['zh', 'ja', 'kr'].includes(locale.language)
this.textDirection = locale.textInfo.direction
} catch(e) {
console.warn(e)
}
if (book.splitTOCHref && book.getTOCFragment) {
const ids = book.sections.map(s => s.id)
this.#sectionProgress = new SectionProgress(book.sections, 150, 1600)
const splitHref = book.splitTOCHref.bind(book)
const getFragment = book.getTOCFragment.bind(book)
this.#tocProgress = new TOCProgress({
toc: book.toc ?? [], ids, splitHref, getFragment })
this.#pageProgress = new TOCProgress({
toc: book.pageList ?? [], ids, splitHref, getFragment })
}
}
async display() {
const opts = {
book: this.book,
onLoad: this.#onLoad.bind(this),
onRelocated: this.#onRelocated.bind(this),
}
this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated'
if (this.isFixedLayout) {
const { FixedLayout } = await import('./fixed-layout.js')
this.renderer = new FixedLayout(opts)
} else {
const { Paginator } = await import('./paginator.js')
this.renderer = new Paginator(opts)
}
return this.renderer.element
}
#onRelocated(range, index, fraction) {
if (!this.#sectionProgress) return
const progress = this.#sectionProgress.getProgress(index, fraction)
const tocItem = this.#tocProgress.getProgress(index, range)
const pageItem = this.#pageProgress.getProgress(index, range)
const cfi = this.getCFI(index, range)
this.emit?.({ type: 'relocated', ...progress, tocItem, pageItem, cfi })
}
#onLoad(doc, index) {
const { book } = this
// set language and dir if not already set
doc.documentElement.lang ||= this.language
doc.documentElement.dir ||= this.isCJK ? '' : this.textDirection
this.renderer.setStyle(this.#css)
// set `document` background to `doc` background
// this is needed cause the iframe does not fill the whole viewport
const bodyStyle = doc.defaultView.getComputedStyle(doc.body)
document.body.style.background =
bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)'
&& bodyStyle.backgroundImage === 'none'
? doc.defaultView.getComputedStyle(doc.documentElement).background
: bodyStyle.background
// set handlers for links
const section = book.sections[index]
for (const a of doc.querySelectorAll('a[href]'))
a.addEventListener('click', e => {
e.preventDefault()
const href = a.getAttribute('href')
const uri = section?.resolveHref?.(href) ?? href
if (book?.isExternal?.(uri))
this.emit?.({ type: 'external-link', uri })
else if (SSV.isRef(a)) {
const { index, anchor } = book.resolveHref(uri)
const pos = getPosition(a)
Promise.resolve(book.sections[index].createDocument())
.then(doc => [anchor(doc), doc.contentType])
.then(([el, type]) =>
[el?.innerHTML, type, SSV.isNote(el)])
.then(([content, contentType, isNote]) => content
? this.emit?.({
type: 'reference',
href: isNote ? null : uri,
content, contentType, pos,
}) : null)
.catch(e => console.error(e))
return
} else this.goTo(uri)
})
}
getCFI(index, range) {
if (!range) return ''
const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index)
return CFI.joinIndir(baseCFI, CFI.fromRange(range))
}
resolveCFI(cfi) {
if (this.book.resolveCFI)
return this.book.resolveCFI(cfi)
else {
const parts = CFI.parse(cfi)
const index = CFI.fake.toIndex((parts.parent ?? parts).shift())
const anchor = doc => CFI.toRange(doc, parts)
return { index, anchor }
}
}
resolveNavigation(target) {
try {
if (typeof target === 'number') return { index: target }
if (CFI.isCFI.test(target)) return this.resolveCFI(target)
return this.book.resolveHref(target)
} catch (e) {
console.error(e)
console.error(`Could not resolve target ${target}`)
}
}
async goTo(target) {
const resolved = this.resolveNavigation(target)
try {
await this.renderer.goTo(resolved)
return resolved
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
async goToFraction(frac) {
const [index, anchor] = this.#sectionProgress.getSection(frac)
return this.renderer.goTo({ index, anchor })
}
async select(target) {
try {
const obj = await this.resolveNavigation(target)
await this.renderer.goTo({ ...obj, select: true })
} catch(e) {
console.error(e)
console.error(`Could not go to ${target}`)
}
}
goLeft() {
return this.book.dir === 'rtl' ? this.renderer.next() : this.renderer.prev()
}
goRight() {
return this.book.dir === 'rtl' ? this.renderer.prev() : this.renderer.next()
}
setAppearance({ layout, css }) {
if (this.isFixedLayout) return
Object.assign(this.renderer.layout, layout)
this.#css = css
this.renderer.setStyle(css)
this.renderer.render()
}
async * #searchSection(matcher, query, index) {
const doc = await this.book.sections[index].createDocument()
for (const { range, excerpt } of matcher(doc, query))
yield { cfi: this.getCFI(index, range), excerpt }
}
async * #searchBook(matcher, query) {
const { sections } = this.book
for (const [index, { createDocument }] of sections.entries()) {
if (!createDocument) continue
const doc = await createDocument()
const subitems = Array.from(matcher(doc, query), ({ range, excerpt }) =>
({ cfi: this.getCFI(index, range), excerpt }))
const progress = (index + 1) / sections.length
yield { progress }
if (subitems.length) yield { index, subitems }
}
}
async * search(opts) {
const { searchMatcher } = await import('./search.js')
const { query, index } = opts
const matcher = searchMatcher(textWalker,
{ defaultLocale: this.language, ...opts })
const iter = index != null
? this.#searchSection(matcher, query, index)
: this.#searchBook(matcher, query)
for await (const result of iter) yield 'subitems' in result ? {
label: this.#tocProgress.getProgress(result.index)?.label ?? '',
subitems: result.subitems,
} : result
}
}