@caipira/vue-reader 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/composables/useEpubReaderStrategy.d.ts +19 -0
- package/dist/composables/useEpubReaderStrategy.js +1 -0
- package/dist/composables/useEpubReaderWasm.d.ts +2 -0
- package/dist/composables/useEpubReaderWasm.js +131 -0
- package/dist/composables/useEpubReaderZipFetcher.d.ts +2 -0
- package/dist/composables/useEpubReaderZipFetcher.js +29 -0
- package/dist/services/browser/epub.d.ts +4 -0
- package/dist/services/browser/epub.js +22 -0
- package/dist/services/browser/manifest.d.ts +7 -0
- package/dist/services/browser/manifest.js +224 -0
- package/dist/services/browser/url-rewrite.d.ts +5 -0
- package/dist/services/browser/url-rewrite.js +183 -0
- package/dist/services/browser/url.d.ts +11 -0
- package/dist/services/browser/url.js +82 -0
- package/dist/services/browser/zip-fetcher.d.ts +12 -0
- package/dist/services/browser/zip-fetcher.js +123 -0
- package/dist/services/common/progression.d.ts +32 -0
- package/dist/services/common/progression.js +169 -0
- package/dist/services/common/title.d.ts +11 -0
- package/dist/services/common/title.js +40 -0
- package/dist/services/common/word-decorations.d.ts +4 -0
- package/dist/services/common/word-decorations.js +316 -0
- package/dist/services/common/word-lookup.d.ts +4 -0
- package/dist/services/common/word-lookup.js +154 -0
- package/dist/services/wasm/frame-document-bridge.d.ts +1 -0
- package/dist/services/wasm/frame-document-bridge.js +84 -0
- package/dist/services/wasm/wasm-streamer.d.ts +35 -0
- package/dist/services/wasm/wasm-streamer.js +157 -0
- package/dist/src/composables/useEpubReader.d.ts +27788 -0
- package/dist/src/composables/useEpubReader.js +8 -0
- package/dist/src/composables/useEpubReaderController.d.ts +27787 -0
- package/dist/src/composables/useEpubReaderController.js +23 -0
- package/dist/src/composables/useEpubReaderNavigation.d.ts +6 -0
- package/dist/src/composables/useEpubReaderNavigation.js +26 -0
- package/dist/src/composables/useEpubReaderSettings.d.ts +15 -0
- package/dist/src/composables/useEpubReaderSettings.js +8 -0
- package/dist/src/composables/useEpubReaderState.d.ts +22 -0
- package/dist/src/composables/useEpubReaderState.js +29 -0
- package/dist/src/composables/useReaderDictionary.d.ts +8 -0
- package/dist/src/composables/useReaderDictionary.js +13 -0
- package/dist/src/composables/useReaderSettings.d.ts +25 -0
- package/dist/src/composables/useReaderSettings.js +42 -0
- package/dist/src/composables/utils.d.ts +2 -0
- package/dist/src/composables/utils.js +1 -0
- package/dist/src/core/controller.d.ts +27789 -0
- package/dist/src/core/controller.js +262 -0
- package/dist/src/core/fonts.d.ts +1 -0
- package/dist/src/core/fonts.js +55 -0
- package/dist/src/core/settings-store.d.ts +16 -0
- package/dist/src/core/settings-store.js +58 -0
- package/dist/src/core/storage.d.ts +2 -0
- package/dist/src/core/storage.js +38 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +11 -0
- package/dist/src/settings/options.d.ts +20 -0
- package/dist/src/settings/options.js +27 -0
- package/dist/src/types.d.ts +40 -0
- package/dist/src/types.js +1 -0
- package/dist/types/browser.d.ts +1 -0
- package/dist/types/browser.js +1 -0
- package/dist/types/common.d.ts +55 -0
- package/dist/types/common.js +1 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# @caipira/vue-reader
|
|
2
|
+
|
|
3
|
+
Headless EPUB reader toolkit for Vue 3.
|
|
4
|
+
|
|
5
|
+
This package provides a controller-first API and Vue composables for embedding a Readium-based reader in your app.
|
|
6
|
+
|
|
7
|
+
## What is included
|
|
8
|
+
|
|
9
|
+
- Headless reader controller (`createEpubReaderController`)
|
|
10
|
+
- Vue composables (`useEpubReader`, state/navigation/settings helpers)
|
|
11
|
+
- Reader settings helpers (`useReaderSettings`, `setReaderColors`)
|
|
12
|
+
- Dictionary integration hooks (`setReaderDictionary`, `ReaderDictionaryApi`)
|
|
13
|
+
|
|
14
|
+
The default loading strategy is `zip-fetcher` and should be used for production today.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @caipira/vue-reader
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start (zip-fetcher default)
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { useTemplateRef } from 'vue';
|
|
26
|
+
import {
|
|
27
|
+
useEpubReader,
|
|
28
|
+
useEpubReaderState,
|
|
29
|
+
useEpubReaderNavigation,
|
|
30
|
+
setReaderColors,
|
|
31
|
+
} from '@caipira/vue-reader';
|
|
32
|
+
|
|
33
|
+
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
|
34
|
+
|
|
35
|
+
setReaderColors({
|
|
36
|
+
background: '#0f172a',
|
|
37
|
+
text: '#f8fafc',
|
|
38
|
+
highlight: '#facc15',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const reader = useEpubReader(() => props.src, containerRef, {
|
|
42
|
+
assets: {
|
|
43
|
+
fontBaseUrl: '/fonts',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const state = useEpubReaderState(reader);
|
|
48
|
+
const navigation = useEpubReaderNavigation(reader);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Dictionary API
|
|
52
|
+
|
|
53
|
+
The reader uses your dictionary adapter to classify words as `known` or `unknown`.
|
|
54
|
+
Unknown words are highlighted automatically in the viewport.
|
|
55
|
+
|
|
56
|
+
Implement and register a dictionary adapter:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import type {
|
|
60
|
+
ReaderDictionaryApi,
|
|
61
|
+
ReaderDictionaryWordStatus,
|
|
62
|
+
} from '@caipira/vue-reader';
|
|
63
|
+
import { setReaderDictionary } from '@caipira/vue-reader';
|
|
64
|
+
|
|
65
|
+
function createDictionary(): ReaderDictionaryApi {
|
|
66
|
+
const cache = new Map<string, ReaderDictionaryWordStatus>();
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async classifyWords(language, lemmas, contextKey) {
|
|
70
|
+
// Load/sync dictionary data for `language` once, then classify lemmas.
|
|
71
|
+
// `contextKey` can be used as a cache partition key.
|
|
72
|
+
const result = new Map<string, ReaderDictionaryWordStatus>();
|
|
73
|
+
|
|
74
|
+
for (const lemma of lemmas) {
|
|
75
|
+
const status = cache.get(lemma) ?? 'unknown';
|
|
76
|
+
result.set(lemma, status);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
},
|
|
81
|
+
clear() {
|
|
82
|
+
cache.clear();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setReaderDictionary(createDictionary());
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Contract
|
|
91
|
+
|
|
92
|
+
- `classifyWords(language, lemmas, contextKey)`
|
|
93
|
+
Returns a `Map<lemma, 'known' | 'unknown'>`.
|
|
94
|
+
- `clear()`
|
|
95
|
+
Clears adapter caches when reader state is torn down.
|
|
96
|
+
|
|
97
|
+
## Word highlighting and lookup flow
|
|
98
|
+
|
|
99
|
+
When a dictionary is registered with `setReaderDictionary(...)`:
|
|
100
|
+
|
|
101
|
+
- Unknown words are highlighted automatically.
|
|
102
|
+
- Users can `Ctrl`+click (or `Cmd`+click on macOS) a word to trigger lookup.
|
|
103
|
+
|
|
104
|
+
You can handle lookup events by extending your dictionary object with an optional `onWordLookup` callback (matching the UI integration):
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import type { ReaderDictionaryApi } from '@caipira/vue-reader';
|
|
108
|
+
import { setReaderDictionary } from '@caipira/vue-reader';
|
|
109
|
+
|
|
110
|
+
const dictionary = createDictionary() as ReaderDictionaryApi & {
|
|
111
|
+
onWordLookup?: (payload: { language: string; word: string; context: string }) => void;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
dictionary.onWordLookup = ({ language, word, context }) => {
|
|
115
|
+
// Open your dictionary UI (drawer/modal/popover) and query definitions.
|
|
116
|
+
openDictionaryDrawer({ language, word, context });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
setReaderDictionary(dictionary);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The `context` value contains nearby text around the clicked word, useful for disambiguation.
|
|
123
|
+
|
|
124
|
+
## Public API
|
|
125
|
+
|
|
126
|
+
- `createEpubReaderController(srcRef, containerRef, options)`
|
|
127
|
+
- `useEpubReaderController(src, containerRef, options)`
|
|
128
|
+
- `useEpubReader(src, containerRef, options)`
|
|
129
|
+
- `useEpubReaderState(controller)`
|
|
130
|
+
- `useEpubReaderNavigation(controller)`
|
|
131
|
+
- `useEpubReaderSettings(controller)`
|
|
132
|
+
- `useEpubReaderControllerRef()`
|
|
133
|
+
- `setEpubReaderController(controller)`
|
|
134
|
+
- `setReaderColors({ background, text, highlight })`
|
|
135
|
+
- `setReaderDictionary(dictionary)`
|
|
136
|
+
- `useReaderDictionary()`
|
|
137
|
+
|
|
138
|
+
## Adapters
|
|
139
|
+
|
|
140
|
+
### Preferences adapter
|
|
141
|
+
|
|
142
|
+
Use `preferences` to load and persist reader settings from your app-specific store.
|
|
143
|
+
|
|
144
|
+
### Storage adapter
|
|
145
|
+
|
|
146
|
+
Use `storage` to override default sessionStorage-based locator persistence.
|
|
147
|
+
|
|
148
|
+
## Build
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm run typecheck
|
|
152
|
+
npm run build
|
|
153
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ChapterBoundary } from '../types/common';
|
|
2
|
+
export type ReaderProgress = {
|
|
3
|
+
progress: number;
|
|
4
|
+
resourceProgression: number;
|
|
5
|
+
};
|
|
6
|
+
export type ReaderProgressCalculator = (idx: number, rawProg?: number) => ReaderProgress;
|
|
7
|
+
export type ReaderSourcePayload = {
|
|
8
|
+
publication?: unknown;
|
|
9
|
+
manifest?: unknown;
|
|
10
|
+
fetcher?: unknown;
|
|
11
|
+
chapterProgressions: Map<string, ChapterBoundary[]>;
|
|
12
|
+
createPositions?: (publication: unknown) => Promise<any[]>;
|
|
13
|
+
createProgressCalculator: (publication: unknown) => ReaderProgressCalculator;
|
|
14
|
+
onFrameLoaded?: (wnd: Window, chapterHref: string) => void;
|
|
15
|
+
};
|
|
16
|
+
export type ReaderSourceStrategy = {
|
|
17
|
+
load: (url: string) => Promise<ReaderSourcePayload>;
|
|
18
|
+
destroy: () => void;
|
|
19
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { resolveHref } from '../services/browser/url';
|
|
2
|
+
import { computePublicationChaptersSize } from '../services/common/progression';
|
|
3
|
+
import { loadPublication, createPublication, unloadPublication, } from '../services/wasm/wasm-streamer';
|
|
4
|
+
import { buildWeightsFromResolver } from '../services/common/progression';
|
|
5
|
+
import { installFrameDocumentBridge } from '../services/wasm/frame-document-bridge';
|
|
6
|
+
const isExternalRef = (value) => {
|
|
7
|
+
const ref = value.trim().toLowerCase();
|
|
8
|
+
return (ref === '' ||
|
|
9
|
+
ref.startsWith('#') ||
|
|
10
|
+
ref.startsWith('data:') ||
|
|
11
|
+
ref.startsWith('blob:') ||
|
|
12
|
+
ref.startsWith('javascript:') ||
|
|
13
|
+
ref.startsWith('mailto:') ||
|
|
14
|
+
ref.startsWith('tel:') ||
|
|
15
|
+
/^[a-z][a-z0-9+.-]*:/.test(ref));
|
|
16
|
+
};
|
|
17
|
+
const normalizeToStreamerUrl = (href, chapterHref, webpubPrefix) => {
|
|
18
|
+
const trimmed = href.trim();
|
|
19
|
+
if (isExternalRef(trimmed)) {
|
|
20
|
+
return href;
|
|
21
|
+
}
|
|
22
|
+
if (trimmed.startsWith(webpubPrefix)) {
|
|
23
|
+
return trimmed;
|
|
24
|
+
}
|
|
25
|
+
const resolved = resolveHref(chapterHref, trimmed);
|
|
26
|
+
if (isExternalRef(resolved)) {
|
|
27
|
+
return href;
|
|
28
|
+
}
|
|
29
|
+
return `${webpubPrefix}${resolved}`;
|
|
30
|
+
};
|
|
31
|
+
const normalizeFrameResourceUrls = (frameWindow, chapterHref, webpubPrefix) => {
|
|
32
|
+
const doc = frameWindow.document;
|
|
33
|
+
if (!doc)
|
|
34
|
+
return;
|
|
35
|
+
const attrMap = [
|
|
36
|
+
{ selector: 'img[src]', attr: 'src' },
|
|
37
|
+
{ selector: 'image[href]', attr: 'href' },
|
|
38
|
+
{ selector: 'source[src]', attr: 'src' },
|
|
39
|
+
{ selector: 'video[src]', attr: 'src' },
|
|
40
|
+
{ selector: 'audio[src]', attr: 'src' },
|
|
41
|
+
{ selector: 'track[src]', attr: 'src' },
|
|
42
|
+
{ selector: 'script[src]', attr: 'src' },
|
|
43
|
+
{ selector: 'link[href]', attr: 'href' },
|
|
44
|
+
{ selector: 'source[srcset]', attr: 'srcset' },
|
|
45
|
+
{ selector: 'img[srcset]', attr: 'srcset' },
|
|
46
|
+
];
|
|
47
|
+
for (const { selector, attr } of attrMap) {
|
|
48
|
+
const nodes = doc.querySelectorAll(selector);
|
|
49
|
+
nodes.forEach((node) => {
|
|
50
|
+
const current = node.getAttribute(attr);
|
|
51
|
+
if (!current) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (attr === 'srcset') {
|
|
55
|
+
const rewritten = current
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const trimmed = entry.trim();
|
|
59
|
+
if (!trimmed) {
|
|
60
|
+
return trimmed;
|
|
61
|
+
}
|
|
62
|
+
const firstSpace = trimmed.search(/\s/);
|
|
63
|
+
if (firstSpace < 0) {
|
|
64
|
+
return normalizeToStreamerUrl(trimmed, chapterHref, webpubPrefix);
|
|
65
|
+
}
|
|
66
|
+
const urlPart = trimmed.slice(0, firstSpace);
|
|
67
|
+
const descriptor = trimmed.slice(firstSpace);
|
|
68
|
+
return `${normalizeToStreamerUrl(urlPart, chapterHref, webpubPrefix)}${descriptor}`;
|
|
69
|
+
})
|
|
70
|
+
.join(', ');
|
|
71
|
+
node.setAttribute(attr, rewritten);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const normalized = normalizeToStreamerUrl(current, chapterHref, webpubPrefix);
|
|
75
|
+
node.setAttribute(attr, normalized);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
export function useEpubReaderWasm() {
|
|
80
|
+
let activeBookId = null;
|
|
81
|
+
let webpubPrefix = '';
|
|
82
|
+
let uninstallFrameDocumentBridge = null;
|
|
83
|
+
const load = async (url) => {
|
|
84
|
+
const bookId = btoa(url).replace(/[/+=]/g, '_');
|
|
85
|
+
activeBookId = bookId;
|
|
86
|
+
webpubPrefix = `/epub-streamer/webpub/${bookId}/`;
|
|
87
|
+
uninstallFrameDocumentBridge = installFrameDocumentBridge();
|
|
88
|
+
await loadPublication(bookId, url);
|
|
89
|
+
const publication = await createPublication(bookId);
|
|
90
|
+
const chaptersSize = await computePublicationChaptersSize(publication);
|
|
91
|
+
return {
|
|
92
|
+
publication,
|
|
93
|
+
chapterProgressions: new Map(),
|
|
94
|
+
createPositions: async (publication) => {
|
|
95
|
+
const pub = publication;
|
|
96
|
+
const manifestPositions = await pub.positionsFromManifest();
|
|
97
|
+
return (manifestPositions ?? [])
|
|
98
|
+
.filter((locator) => !!locator)
|
|
99
|
+
.map((locator, index) => {
|
|
100
|
+
if (locator.locations) {
|
|
101
|
+
return locator;
|
|
102
|
+
}
|
|
103
|
+
return locator.copyWithLocations({
|
|
104
|
+
position: index + 1,
|
|
105
|
+
progression: 0,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
createProgressCalculator: (publication) => buildWeightsFromResolver(publication, (href) => {
|
|
110
|
+
const baseHref = href.split('#')[0];
|
|
111
|
+
return chaptersSize.get(baseHref);
|
|
112
|
+
}),
|
|
113
|
+
onFrameLoaded: (wnd, chapterHref) => {
|
|
114
|
+
normalizeFrameResourceUrls(wnd, chapterHref, webpubPrefix);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const destroy = () => {
|
|
119
|
+
if (activeBookId) {
|
|
120
|
+
void unloadPublication(activeBookId);
|
|
121
|
+
activeBookId = null;
|
|
122
|
+
}
|
|
123
|
+
uninstallFrameDocumentBridge?.();
|
|
124
|
+
uninstallFrameDocumentBridge = null;
|
|
125
|
+
webpubPrefix = '';
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
load,
|
|
129
|
+
destroy,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import ZipFetcher from '../services/browser/zip-fetcher';
|
|
2
|
+
import { buildManifest } from '../services/browser/manifest';
|
|
3
|
+
import { clearBlobUrlCache } from '../services/browser/url-rewrite';
|
|
4
|
+
import { fetchEpub, unzipEpub } from '../services/browser/epub';
|
|
5
|
+
import { buildWeightsFromZip, computeChapterProgressions, } from '../services/common/progression';
|
|
6
|
+
export function useEpubReaderZipFetcher() {
|
|
7
|
+
const load = async (url) => {
|
|
8
|
+
const zip = await unzipEpub(await fetchEpub(url));
|
|
9
|
+
const manifest = buildManifest(zip);
|
|
10
|
+
const fetcher = new ZipFetcher(zip);
|
|
11
|
+
const chapterProgressions = manifest.toc
|
|
12
|
+
? computeChapterProgressions(manifest.toc.items, zip)
|
|
13
|
+
: new Map();
|
|
14
|
+
const createProgressCalculator = (publication) => buildWeightsFromZip(publication, zip);
|
|
15
|
+
return {
|
|
16
|
+
manifest,
|
|
17
|
+
fetcher,
|
|
18
|
+
chapterProgressions,
|
|
19
|
+
createProgressCalculator,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const destroy = () => {
|
|
23
|
+
clearBlobUrlCache();
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
load,
|
|
27
|
+
destroy,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import JSZip from 'jszip';
|
|
2
|
+
const fetchEpub = async (url) => {
|
|
3
|
+
const res = await fetch(url);
|
|
4
|
+
if (!res.ok) {
|
|
5
|
+
throw new Error(res.statusText || 'Failed to fetch EPUB');
|
|
6
|
+
}
|
|
7
|
+
return res.arrayBuffer();
|
|
8
|
+
};
|
|
9
|
+
const unzipEpub = async (buf) => {
|
|
10
|
+
const zip = await JSZip.loadAsync(buf);
|
|
11
|
+
const entries = {};
|
|
12
|
+
const tasks = [];
|
|
13
|
+
zip.forEach((relPath, entry) => {
|
|
14
|
+
tasks.push(entry.async('uint8array').then((data) => {
|
|
15
|
+
const key = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
16
|
+
entries[key] = data;
|
|
17
|
+
}));
|
|
18
|
+
});
|
|
19
|
+
await Promise.all(tasks);
|
|
20
|
+
return entries;
|
|
21
|
+
};
|
|
22
|
+
export { fetchEpub, unzipEpub };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ZipData } from '../../types/browser';
|
|
2
|
+
import { Manifest } from '@readium/shared';
|
|
3
|
+
/**
|
|
4
|
+
* Build a RWPM Manifest from the OPF file and related resources in the zip.
|
|
5
|
+
*/
|
|
6
|
+
declare const buildManifest: (zip: ZipData) => Manifest;
|
|
7
|
+
export { buildManifest };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Link, Links, Metadata, Manifest, Contributor, Contributors, LocalizedString, ReadingProgression, } from '@readium/shared';
|
|
2
|
+
import { guessMediaType, getExt, resolveHref } from '../../services/browser/url';
|
|
3
|
+
const parseNavDoc = (html, baseDir) => {
|
|
4
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
5
|
+
const nav = doc.querySelector('nav[epub\\:type="toc"]') ??
|
|
6
|
+
doc.querySelector('nav[type="toc"]') ??
|
|
7
|
+
doc.querySelector('nav#toc, .toc');
|
|
8
|
+
if (!nav) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
return Array.from(nav.querySelectorAll('a'))
|
|
12
|
+
.map((a) => {
|
|
13
|
+
const href = a.getAttribute('href');
|
|
14
|
+
const title = a.textContent?.trim();
|
|
15
|
+
if (!href || !title) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return new Link({
|
|
19
|
+
href: resolveHref(baseDir, href),
|
|
20
|
+
type: 'application/xhtml+xml',
|
|
21
|
+
title,
|
|
22
|
+
});
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
};
|
|
26
|
+
const parseNcx = (xml, baseDir) => {
|
|
27
|
+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
|
|
28
|
+
return Array.from(doc.querySelectorAll('navPoint'))
|
|
29
|
+
.map((np) => {
|
|
30
|
+
const content = np.querySelector('content');
|
|
31
|
+
const text = np.querySelector('navLabel > text');
|
|
32
|
+
const href = content?.getAttribute('src');
|
|
33
|
+
const title = text?.textContent?.trim();
|
|
34
|
+
if (!href || !title) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return new Link({
|
|
38
|
+
href: resolveHref(baseDir, href),
|
|
39
|
+
type: 'application/xhtml+xml',
|
|
40
|
+
title,
|
|
41
|
+
});
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
};
|
|
45
|
+
const readText = (zip, path) => {
|
|
46
|
+
const data = zip[path];
|
|
47
|
+
if (!data) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const decoder = new TextDecoder('utf-8');
|
|
51
|
+
return decoder.decode(data).replace(/^\uFEFF/, '');
|
|
52
|
+
};
|
|
53
|
+
const readXml = (zip, path) => {
|
|
54
|
+
const text = readText(zip, path);
|
|
55
|
+
if (!text) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return new DOMParser().parseFromString(text, 'application/xml');
|
|
59
|
+
};
|
|
60
|
+
const getOpfPath = (zip) => {
|
|
61
|
+
const doc = readXml(zip, 'META-INF/container.xml');
|
|
62
|
+
if (!doc) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const rf = doc.querySelector('rootfile');
|
|
66
|
+
return rf?.getAttribute('full-path') ?? null;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Build a RWPM Manifest from the OPF file and related resources in the zip.
|
|
70
|
+
*/
|
|
71
|
+
const buildManifest = (zip) => {
|
|
72
|
+
const opfPath = getOpfPath(zip);
|
|
73
|
+
if (!opfPath) {
|
|
74
|
+
throw new Error('Could not locate OPF (missing META-INF/container.xml)');
|
|
75
|
+
}
|
|
76
|
+
const baseDir = opfPath.substring(0, opfPath.lastIndexOf('/') + 1);
|
|
77
|
+
const opfDoc = readXml(zip, opfPath);
|
|
78
|
+
if (!opfDoc) {
|
|
79
|
+
throw new Error('Cannot parse OPF file');
|
|
80
|
+
}
|
|
81
|
+
const pkg = opfDoc.querySelector('package');
|
|
82
|
+
if (!pkg) {
|
|
83
|
+
throw new Error('Invalid OPF: missing <package>');
|
|
84
|
+
}
|
|
85
|
+
// Reading progression direction
|
|
86
|
+
const progressionDir = pkg.getAttribute('page-progression-direction') ?? pkg.getAttribute('dir');
|
|
87
|
+
const readingProgression = progressionDir === 'rtl' ? ReadingProgression.rtl : ReadingProgression.ltr;
|
|
88
|
+
// Metadata
|
|
89
|
+
const meta = pkg.querySelector('metadata');
|
|
90
|
+
const dc = (tag) => {
|
|
91
|
+
for (const child of meta?.children ?? []) {
|
|
92
|
+
const n = child.nodeName.toLowerCase();
|
|
93
|
+
if (n === `dc:${tag}` || child.localName === tag) {
|
|
94
|
+
return child.textContent ?? undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
const title = dc('title') || 'Untitled';
|
|
100
|
+
const author = dc('creator');
|
|
101
|
+
const lang = dc('language');
|
|
102
|
+
const identifier = dc('identifier');
|
|
103
|
+
const authors = author
|
|
104
|
+
? new Contributors([
|
|
105
|
+
new Contributor({ name: new LocalizedString({ und: author }) }),
|
|
106
|
+
])
|
|
107
|
+
: undefined;
|
|
108
|
+
const metadata = new Metadata({
|
|
109
|
+
title: new LocalizedString({ und: title }),
|
|
110
|
+
authors,
|
|
111
|
+
languages: lang ? [lang] : undefined,
|
|
112
|
+
identifier,
|
|
113
|
+
readingProgression,
|
|
114
|
+
});
|
|
115
|
+
// Manifest items
|
|
116
|
+
const items = pkg.querySelectorAll('manifest > item');
|
|
117
|
+
const idMap = new Map();
|
|
118
|
+
const hrefSet = new Set();
|
|
119
|
+
items.forEach((el) => {
|
|
120
|
+
const href = el.getAttribute('href');
|
|
121
|
+
if (!href) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const fullHref = baseDir + href;
|
|
125
|
+
const id = el.getAttribute('id') ?? fullHref;
|
|
126
|
+
const type = el.getAttribute('media-type') || guessMediaType(getExt(href));
|
|
127
|
+
const link = new Link({ href: fullHref, type });
|
|
128
|
+
idMap.set(id, link);
|
|
129
|
+
hrefSet.add(fullHref);
|
|
130
|
+
});
|
|
131
|
+
// Spine (reading order)
|
|
132
|
+
const spineLinks = [];
|
|
133
|
+
const spineHrefs = new Set();
|
|
134
|
+
const spineRefs = pkg.querySelectorAll('spine > itemref');
|
|
135
|
+
spineRefs.forEach((ref) => {
|
|
136
|
+
const idref = ref.getAttribute('idref');
|
|
137
|
+
if (!idref) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const link = idMap.get(idref);
|
|
141
|
+
if (link && !spineHrefs.has(link.href)) {
|
|
142
|
+
spineHrefs.add(link.href);
|
|
143
|
+
spineLinks.push(link);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
const readingOrder = new Links(spineLinks);
|
|
147
|
+
// Resources (non-spine)
|
|
148
|
+
const resLinks = [];
|
|
149
|
+
hrefSet.forEach((h) => {
|
|
150
|
+
if (!spineHrefs.has(h)) {
|
|
151
|
+
const l = Array.from(idMap.values()).find((lk) => lk.href === h);
|
|
152
|
+
if (l)
|
|
153
|
+
resLinks.push(l);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const resources = new Links(resLinks);
|
|
157
|
+
// Table of contents (EPUB 3 nav / EPUB 2 NCX)
|
|
158
|
+
let toc = [];
|
|
159
|
+
const navItem = Array.from(items).find((el) => el.getAttribute('properties') === 'nav');
|
|
160
|
+
if (navItem) {
|
|
161
|
+
const navHref = navItem.getAttribute('href');
|
|
162
|
+
if (navHref) {
|
|
163
|
+
const navPath = baseDir + navHref;
|
|
164
|
+
const navDir = navPath.substring(0, navPath.lastIndexOf('/') + 1);
|
|
165
|
+
const html = readText(zip, navPath);
|
|
166
|
+
if (html) {
|
|
167
|
+
toc = parseNavDoc(html, navDir);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (toc.length === 0) {
|
|
172
|
+
const ncxItem = Array.from(items).find((el) => el.getAttribute('media-type') === 'application/x-dtbncx+xml');
|
|
173
|
+
if (ncxItem) {
|
|
174
|
+
const ncxHref = ncxItem.getAttribute('href');
|
|
175
|
+
if (ncxHref) {
|
|
176
|
+
const ncxPath = baseDir + ncxHref;
|
|
177
|
+
const ncxDir = ncxPath.substring(0, ncxPath.lastIndexOf('/') + 1);
|
|
178
|
+
const xml = readText(zip, ncxPath);
|
|
179
|
+
if (xml) {
|
|
180
|
+
toc = parseNcx(xml, ncxDir);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Cross-reference TOC titles back to spine (reading order) links
|
|
186
|
+
// so that the navigator can report chapter titles on position changes.
|
|
187
|
+
//
|
|
188
|
+
// Many EPUBs use fragment anchors in the TOC (e.g. "part0000.xhtml#_Toc1")
|
|
189
|
+
// while the spine href has no fragment. We match on the base href (no fragment)
|
|
190
|
+
// and take the *first* TOC title for that file as a fallback. The
|
|
191
|
+
// positionChanged listener will do a more precise fragment-aware lookup.
|
|
192
|
+
if (toc.length > 0) {
|
|
193
|
+
// Map from base href (no fragment) → first TOC title for that file
|
|
194
|
+
const tocBaseTitleMap = new Map();
|
|
195
|
+
// Also keep the full TOC list for fragment-aware matching at runtime
|
|
196
|
+
const stripFragment = (href) => href.split('#')[0];
|
|
197
|
+
for (const tocLink of toc) {
|
|
198
|
+
if (!tocLink.title) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const base = stripFragment(tocLink.href);
|
|
202
|
+
if (!tocBaseTitleMap.has(base)) {
|
|
203
|
+
tocBaseTitleMap.set(base, tocLink.title);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Apply matched titles to spine links
|
|
207
|
+
for (const spineLink of spineLinks) {
|
|
208
|
+
const base = stripFragment(spineLink.href);
|
|
209
|
+
const title = tocBaseTitleMap.get(base);
|
|
210
|
+
if (title) {
|
|
211
|
+
spineLink.title = title;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return new Manifest({
|
|
216
|
+
context: ['https://readium.org/webpub-manifest/context.jsonld'],
|
|
217
|
+
metadata,
|
|
218
|
+
links: new Links([]),
|
|
219
|
+
readingOrder,
|
|
220
|
+
resources,
|
|
221
|
+
toc: toc.length > 0 ? new Links(toc) : undefined,
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
export { buildManifest };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ZipData } from '../../types/browser';
|
|
2
|
+
declare const clearBlobUrlCache: () => void;
|
|
3
|
+
declare const rewriteXhtmlUrls: (doc: Document, resourceHref: string, zip: ZipData) => void;
|
|
4
|
+
declare const rewriteCssUrls: (css: string, resourceHref: string, zip: ZipData) => string;
|
|
5
|
+
export { clearBlobUrlCache, rewriteXhtmlUrls, rewriteCssUrls };
|