@ankhorage/expo-runtime 3.1.1 → 3.2.2
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/CHANGELOG.md +24 -0
- package/README.md +2 -1
- package/dist/barcodeScanner.d.ts +2 -0
- package/dist/barcodeScanner.d.ts.map +1 -0
- package/dist/barcodeScanner.js +2 -0
- package/dist/barcodeScanner.js.map +1 -0
- package/dist/createExpoRuntimeRegistry.d.ts.map +1 -1
- package/dist/createExpoRuntimeRegistry.js +5 -0
- package/dist/createExpoRuntimeRegistry.js.map +1 -1
- package/dist/expoRuntimePlanningMetadata.d.ts +9 -1
- package/dist/expoRuntimePlanningMetadata.d.ts.map +1 -1
- package/dist/expoRuntimePlanningMetadata.js +8 -0
- package/dist/expoRuntimePlanningMetadata.js.map +1 -1
- package/dist/generatedSources.d.ts +1 -0
- package/dist/generatedSources.d.ts.map +1 -1
- package/dist/generatedSources.js +9 -1
- package/dist/generatedSources.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/planning.d.ts +1 -1
- package/dist/planning.d.ts.map +1 -1
- package/dist/planning.js +1 -1
- package/dist/planning.js.map +1 -1
- package/dist/reader/ExpoReaderSurfaceAdapter.d.ts +4 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.d.ts.map +1 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.js +102 -0
- package/dist/reader/ExpoReaderSurfaceAdapter.js.map +1 -0
- package/dist/reader/ReaderDocumentError.d.ts +6 -0
- package/dist/reader/ReaderDocumentError.d.ts.map +1 -0
- package/dist/reader/ReaderDocumentError.js +9 -0
- package/dist/reader/ReaderDocumentError.js.map +1 -0
- package/dist/reader/ReaderViewport.d.ts +4 -0
- package/dist/reader/ReaderViewport.d.ts.map +1 -0
- package/dist/reader/ReaderViewport.js +212 -0
- package/dist/reader/ReaderViewport.js.map +1 -0
- package/dist/reader/drivers/EpubReaderDriver.d.ts +20 -0
- package/dist/reader/drivers/EpubReaderDriver.d.ts.map +1 -0
- package/dist/reader/drivers/EpubReaderDriver.js +259 -0
- package/dist/reader/drivers/EpubReaderDriver.js.map +1 -0
- package/dist/reader/drivers/PdfReaderDriver.d.ts +18 -0
- package/dist/reader/drivers/PdfReaderDriver.d.ts.map +1 -0
- package/dist/reader/drivers/PdfReaderDriver.js +198 -0
- package/dist/reader/drivers/PdfReaderDriver.js.map +1 -0
- package/dist/reader/epub/EpubArchive.d.ts +11 -0
- package/dist/reader/epub/EpubArchive.d.ts.map +1 -0
- package/dist/reader/epub/EpubArchive.js +65 -0
- package/dist/reader/epub/EpubArchive.js.map +1 -0
- package/dist/reader/epub/EpubArchiveFetcher.d.ts +11 -0
- package/dist/reader/epub/EpubArchiveFetcher.d.ts.map +1 -0
- package/dist/reader/epub/EpubArchiveFetcher.js +44 -0
- package/dist/reader/epub/EpubArchiveFetcher.js.map +1 -0
- package/dist/reader/epub/mediaTypes.d.ts +3 -0
- package/dist/reader/epub/mediaTypes.d.ts.map +1 -0
- package/dist/reader/epub/mediaTypes.js +26 -0
- package/dist/reader/epub/mediaTypes.js.map +1 -0
- package/dist/reader/epub/parseEpubPackage.d.ts +22 -0
- package/dist/reader/epub/parseEpubPackage.d.ts.map +1 -0
- package/dist/reader/epub/parseEpubPackage.js +98 -0
- package/dist/reader/epub/parseEpubPackage.js.map +1 -0
- package/dist/reader/epub/path.d.ts +4 -0
- package/dist/reader/epub/path.d.ts.map +1 -0
- package/dist/reader/epub/path.js +28 -0
- package/dist/reader/epub/path.js.map +1 -0
- package/dist/reader/epub/sanitizeEpubDocument.d.ts +3 -0
- package/dist/reader/epub/sanitizeEpubDocument.d.ts.map +1 -0
- package/dist/reader/epub/sanitizeEpubDocument.js +90 -0
- package/dist/reader/epub/sanitizeEpubDocument.js.map +1 -0
- package/dist/reader/index.d.ts +2 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +2 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/loadReaderSourceAsync.d.ts +2 -0
- package/dist/reader/loadReaderSourceAsync.d.ts.map +1 -0
- package/dist/reader/loadReaderSourceAsync.js +49 -0
- package/dist/reader/loadReaderSourceAsync.js.map +1 -0
- package/dist/reader/readerViewportPresentation.d.ts +4 -0
- package/dist/reader/readerViewportPresentation.d.ts.map +1 -0
- package/dist/reader/readerViewportPresentation.js +26 -0
- package/dist/reader/readerViewportPresentation.js.map +1 -0
- package/dist/reader/types.d.ts +57 -0
- package/dist/reader/types.d.ts.map +1 -0
- package/dist/reader/types.js +2 -0
- package/dist/reader/types.js.map +1 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.d.ts +3 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.d.ts.map +1 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.js +48 -0
- package/dist/reader/validateEpubArchiveEntriesAsync.js.map +1 -0
- package/package.json +25 -4
- package/src/adapterEntrypoints.test.ts +48 -0
- package/src/barcodeScanner.ts +1 -0
- package/src/createExpoRuntimeRegistry.test.tsx +9 -1
- package/src/createExpoRuntimeRegistry.tsx +7 -1
- package/src/expoRuntimePlanningMetadata.ts +9 -1
- package/src/generatedSources.test.ts +11 -2
- package/src/generatedSources.ts +11 -1
- package/src/index.ts +5 -1
- package/src/planning.ts +4 -1
- package/src/reader/ExpoReaderSurfaceAdapter.tsx +170 -0
- package/src/reader/ReaderDocumentError.ts +11 -0
- package/src/reader/ReaderViewport.tsx +319 -0
- package/src/reader/drivers/EpubReaderDriver.ts +309 -0
- package/src/reader/drivers/PdfReaderDriver.ts +253 -0
- package/src/reader/epub/EpubArchive.ts +83 -0
- package/src/reader/epub/EpubArchiveFetcher.ts +58 -0
- package/src/reader/epub/mediaTypes.ts +16 -0
- package/src/reader/epub/parseEpubPackage.test.ts +116 -0
- package/src/reader/epub/parseEpubPackage.ts +152 -0
- package/src/reader/epub/path.test.ts +22 -0
- package/src/reader/epub/path.ts +26 -0
- package/src/reader/epub/sanitizeEpubDocument.ts +121 -0
- package/src/reader/index.ts +1 -0
- package/src/reader/loadReaderSourceAsync.test.ts +52 -0
- package/src/reader/loadReaderSourceAsync.ts +59 -0
- package/src/reader/readerViewportPresentation.ts +28 -0
- package/src/reader/rendererPackages.test.ts +3 -1
- package/src/reader/types.ts +61 -0
- package/src/reader/validateEpubArchiveEntriesAsync.test.ts +142 -0
- package/src/reader/validateEpubArchiveEntriesAsync.ts +73 -0
- package/src/resolveExpoRuntimePlan.test.ts +36 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/* eslint-disable max-lines-per-function, complexity -- EPUB assembly keeps archive cleanup transactional. */
|
|
2
|
+
import { EpubNavigator, type EpubNavigatorListeners } from '@readium/navigator';
|
|
3
|
+
import { Locator, Manifest, Publication } from '@readium/shared';
|
|
4
|
+
|
|
5
|
+
import { EpubArchive } from '../epub/EpubArchive';
|
|
6
|
+
import { EpubArchiveFetcher } from '../epub/EpubArchiveFetcher';
|
|
7
|
+
import { HTML_MEDIA_TYPES, inferEpubMediaType } from '../epub/mediaTypes';
|
|
8
|
+
import { type ParsedEpubPackage, parseEpubPackage } from '../epub/parseEpubPackage';
|
|
9
|
+
import { sanitizeEpubDocument, sanitizeEpubStylesheet } from '../epub/sanitizeEpubDocument';
|
|
10
|
+
import type { ReaderAppearance, ReaderDriver, ReaderDriverState } from '../types';
|
|
11
|
+
|
|
12
|
+
export class EpubReaderDriver implements ReaderDriver {
|
|
13
|
+
readonly direction: 'ltr' | 'rtl';
|
|
14
|
+
readonly #archive: EpubArchive;
|
|
15
|
+
readonly #navigator: EpubNavigator;
|
|
16
|
+
readonly #objectUrls: string[];
|
|
17
|
+
readonly #onExternalLink: (url: string) => void;
|
|
18
|
+
readonly #onStateChange: (state: ReaderDriverState) => void;
|
|
19
|
+
readonly #publication: Publication;
|
|
20
|
+
readonly #fixedLayout: boolean;
|
|
21
|
+
#page = 1;
|
|
22
|
+
#suppressStateNotifications = false;
|
|
23
|
+
|
|
24
|
+
private constructor(args: {
|
|
25
|
+
archive: EpubArchive;
|
|
26
|
+
direction: 'ltr' | 'rtl';
|
|
27
|
+
fixedLayout: boolean;
|
|
28
|
+
navigator: EpubNavigator;
|
|
29
|
+
objectUrls: string[];
|
|
30
|
+
onExternalLink: (url: string) => void;
|
|
31
|
+
onStateChange: (state: ReaderDriverState) => void;
|
|
32
|
+
publication: Publication;
|
|
33
|
+
}) {
|
|
34
|
+
this.#archive = args.archive;
|
|
35
|
+
this.direction = args.direction;
|
|
36
|
+
this.#fixedLayout = args.fixedLayout;
|
|
37
|
+
this.#navigator = args.navigator;
|
|
38
|
+
this.#objectUrls = args.objectUrls;
|
|
39
|
+
this.#onExternalLink = args.onExternalLink;
|
|
40
|
+
this.#onStateChange = args.onStateChange;
|
|
41
|
+
this.#publication = args.publication;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static async create(args: {
|
|
45
|
+
appearance: ReaderAppearance;
|
|
46
|
+
bytes: Uint8Array;
|
|
47
|
+
container: HTMLElement;
|
|
48
|
+
initialLocation?: string;
|
|
49
|
+
onExternalLink: (url: string) => void;
|
|
50
|
+
onStateChange: (state: ReaderDriverState) => void;
|
|
51
|
+
signal: AbortSignal;
|
|
52
|
+
}): Promise<EpubReaderDriver> {
|
|
53
|
+
const archive = await EpubArchive.open(args.bytes, args.signal);
|
|
54
|
+
const objectUrls: string[] = [];
|
|
55
|
+
try {
|
|
56
|
+
const packageData = await parseEpubPackage(archive, args.signal);
|
|
57
|
+
const { publication, objectUrls: createdUrls } = await createPublication(
|
|
58
|
+
archive,
|
|
59
|
+
packageData,
|
|
60
|
+
args.signal,
|
|
61
|
+
);
|
|
62
|
+
objectUrls.push(...createdUrls);
|
|
63
|
+
const holder: { driver?: EpubReaderDriver } = {};
|
|
64
|
+
const listeners: EpubNavigatorListeners = {
|
|
65
|
+
frameLoaded: (window) =>
|
|
66
|
+
attachFrameLinkPolicy(window, (url) => {
|
|
67
|
+
if (holder.driver !== undefined) holder.driver.#onExternalLink(url);
|
|
68
|
+
}),
|
|
69
|
+
positionChanged: () => {
|
|
70
|
+
if (holder.driver !== undefined) holder.driver.#notifyState();
|
|
71
|
+
},
|
|
72
|
+
timelineItemChanged: () => {
|
|
73
|
+
if (holder.driver !== undefined) holder.driver.#notifyState();
|
|
74
|
+
},
|
|
75
|
+
tap: () => false,
|
|
76
|
+
click: () => false,
|
|
77
|
+
zoom: noop,
|
|
78
|
+
miscPointer: noop,
|
|
79
|
+
scroll: noop,
|
|
80
|
+
customEvent: noop,
|
|
81
|
+
handleLocator: () => false,
|
|
82
|
+
textSelected: noop,
|
|
83
|
+
contentProtection: noop,
|
|
84
|
+
contextMenu: noop,
|
|
85
|
+
peripheral: noop,
|
|
86
|
+
};
|
|
87
|
+
const initialPosition = parseInitialLocator(args.initialLocation);
|
|
88
|
+
const navigator = new EpubNavigator(
|
|
89
|
+
args.container,
|
|
90
|
+
publication,
|
|
91
|
+
listeners,
|
|
92
|
+
[],
|
|
93
|
+
initialPosition,
|
|
94
|
+
{
|
|
95
|
+
defaults: {},
|
|
96
|
+
preferences: resolvePreferences(args.appearance),
|
|
97
|
+
injectables: { allowedDomains: [], rules: [] },
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
const driver = new EpubReaderDriver({
|
|
101
|
+
archive,
|
|
102
|
+
direction: packageData.direction,
|
|
103
|
+
fixedLayout: packageData.fixedLayout,
|
|
104
|
+
navigator,
|
|
105
|
+
objectUrls,
|
|
106
|
+
onExternalLink: args.onExternalLink,
|
|
107
|
+
onStateChange: args.onStateChange,
|
|
108
|
+
publication,
|
|
109
|
+
});
|
|
110
|
+
holder.driver = driver;
|
|
111
|
+
await navigator.load();
|
|
112
|
+
driver.#notifyState();
|
|
113
|
+
return driver;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
116
|
+
await archive.close();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getState(): ReaderDriverState {
|
|
122
|
+
const locator = this.#navigator.currentLocator;
|
|
123
|
+
const spineIndex = Math.max(
|
|
124
|
+
0,
|
|
125
|
+
this.#publication.readingOrder.items.findIndex((link) => link.href === locator.href),
|
|
126
|
+
);
|
|
127
|
+
const progressionInResource = locator.locations.progression ?? 0;
|
|
128
|
+
const totalProgression =
|
|
129
|
+
locator.locations.totalProgression ??
|
|
130
|
+
(spineIndex + progressionInResource) / this.#publication.readingOrder.items.length;
|
|
131
|
+
if (this.#fixedLayout) this.#page = spineIndex + 1;
|
|
132
|
+
return {
|
|
133
|
+
canGoNext: this.#navigator.canGoForward,
|
|
134
|
+
canGoPrevious: this.#navigator.canGoBackward,
|
|
135
|
+
chapterId: locator.href,
|
|
136
|
+
chapterTitle: locator.title,
|
|
137
|
+
locator: JSON.stringify(locator.serialize()),
|
|
138
|
+
page: this.#page,
|
|
139
|
+
pageCount: this.#fixedLayout ? this.#publication.readingOrder.items.length : undefined,
|
|
140
|
+
progression: Math.max(0, Math.min(1, totalProgression)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async goNext(_trigger: Parameters<ReaderDriver['goNext']>[0]): Promise<void> {
|
|
145
|
+
if (!this.#navigator.canGoForward) return;
|
|
146
|
+
this.#suppressStateNotifications = true;
|
|
147
|
+
try {
|
|
148
|
+
await navigate((done) => this.#navigator.goForward(shouldAnimateNavigation(), done));
|
|
149
|
+
if (!this.#fixedLayout) this.#page += 1;
|
|
150
|
+
} finally {
|
|
151
|
+
this.#suppressStateNotifications = false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async goPrevious(_trigger: Parameters<ReaderDriver['goPrevious']>[0]): Promise<void> {
|
|
156
|
+
if (!this.#navigator.canGoBackward) return;
|
|
157
|
+
this.#suppressStateNotifications = true;
|
|
158
|
+
try {
|
|
159
|
+
await navigate((done) => this.#navigator.goBackward(shouldAnimateNavigation(), done));
|
|
160
|
+
if (!this.#fixedLayout) this.#page = Math.max(1, this.#page - 1);
|
|
161
|
+
} finally {
|
|
162
|
+
this.#suppressStateNotifications = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async destroy(): Promise<void> {
|
|
167
|
+
await this.#navigator.destroy();
|
|
168
|
+
await this.#archive.close();
|
|
169
|
+
this.#objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#notifyState(): void {
|
|
173
|
+
if (!this.#suppressStateNotifications) this.#onStateChange(this.getState());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function createPublication(
|
|
178
|
+
archive: EpubArchive,
|
|
179
|
+
packageData: ParsedEpubPackage,
|
|
180
|
+
signal: AbortSignal,
|
|
181
|
+
): Promise<{ publication: Publication; objectUrls: string[] }> {
|
|
182
|
+
const objectUrls: string[] = [];
|
|
183
|
+
const resourceUrls = new Map<string, string>();
|
|
184
|
+
const itemByPath = new Map(
|
|
185
|
+
[...packageData.readingOrder, ...packageData.resources].map((item) => [item.href, item]),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
for (const path of archive.paths()) {
|
|
189
|
+
const mediaType = itemByPath.get(path)?.mediaType ?? inferEpubMediaType(path);
|
|
190
|
+
if (HTML_MEDIA_TYPES.has(mediaType) || mediaType === 'text/css') continue;
|
|
191
|
+
const resource = await archive.read(path, signal);
|
|
192
|
+
const resourceBuffer = resource.buffer.slice(
|
|
193
|
+
resource.byteOffset,
|
|
194
|
+
resource.byteOffset + resource.byteLength,
|
|
195
|
+
) as ArrayBuffer;
|
|
196
|
+
const url = URL.createObjectURL(new Blob([resourceBuffer], { type: mediaType }));
|
|
197
|
+
objectUrls.push(url);
|
|
198
|
+
resourceUrls.set(path, url);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const overrides = new Map<string, Uint8Array>();
|
|
202
|
+
for (const path of archive.paths()) {
|
|
203
|
+
const mediaType = itemByPath.get(path)?.mediaType ?? inferEpubMediaType(path);
|
|
204
|
+
if (mediaType !== 'text/css') continue;
|
|
205
|
+
const css = sanitizeEpubStylesheet(await archive.readText(path, signal), path, (resourcePath) =>
|
|
206
|
+
resourceUrls.get(resourcePath),
|
|
207
|
+
);
|
|
208
|
+
const url = URL.createObjectURL(new Blob([css], { type: mediaType }));
|
|
209
|
+
objectUrls.push(url);
|
|
210
|
+
resourceUrls.set(path, url);
|
|
211
|
+
overrides.set(path, new TextEncoder().encode(css));
|
|
212
|
+
}
|
|
213
|
+
for (const [path, item] of itemByPath) {
|
|
214
|
+
if (!HTML_MEDIA_TYPES.has(item.mediaType)) continue;
|
|
215
|
+
const source = sanitizeEpubDocument(
|
|
216
|
+
await archive.readText(path, signal),
|
|
217
|
+
path,
|
|
218
|
+
(resourcePath) => resourceUrls.get(resourcePath),
|
|
219
|
+
);
|
|
220
|
+
overrides.set(path, new TextEncoder().encode(source));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const manifest = Manifest.deserialize({
|
|
224
|
+
metadata: {
|
|
225
|
+
conformsTo: ['https://readium.org/webpub-manifest/profiles/epub'],
|
|
226
|
+
language: packageData.language,
|
|
227
|
+
layout: packageData.fixedLayout ? 'fixed' : 'reflowable',
|
|
228
|
+
readingProgression: packageData.direction,
|
|
229
|
+
title: packageData.title,
|
|
230
|
+
},
|
|
231
|
+
links: [
|
|
232
|
+
{
|
|
233
|
+
href: 'https://reader.invalid/manifest.json',
|
|
234
|
+
rel: 'self',
|
|
235
|
+
type: 'application/webpub+json',
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
readingOrder: packageData.readingOrder.map(toManifestLink),
|
|
239
|
+
resources: packageData.resources.map(toManifestLink),
|
|
240
|
+
toc: packageData.tableOfContents.map((item) => ({
|
|
241
|
+
href: item.href,
|
|
242
|
+
title: item.title,
|
|
243
|
+
type: 'application/xhtml+xml',
|
|
244
|
+
})),
|
|
245
|
+
});
|
|
246
|
+
if (manifest === undefined) throw new Error('Could not construct the EPUB publication manifest.');
|
|
247
|
+
const links = [...manifest.readingOrder.items, ...(manifest.resources?.items ?? [])];
|
|
248
|
+
return {
|
|
249
|
+
publication: new Publication({
|
|
250
|
+
fetcher: new EpubArchiveFetcher(archive, links, overrides),
|
|
251
|
+
manifest,
|
|
252
|
+
}),
|
|
253
|
+
objectUrls,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toManifestLink(item: ParsedEpubPackage['readingOrder'][number]) {
|
|
258
|
+
return {
|
|
259
|
+
href: item.href,
|
|
260
|
+
type: item.mediaType,
|
|
261
|
+
properties:
|
|
262
|
+
item.properties.length === 0
|
|
263
|
+
? undefined
|
|
264
|
+
: Object.fromEntries(item.properties.map((key) => [key, true])),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolvePreferences(appearance: ReaderAppearance) {
|
|
269
|
+
const dark = appearance.colorScheme === 'dark';
|
|
270
|
+
const sepia = appearance.colorScheme === 'sepia';
|
|
271
|
+
return {
|
|
272
|
+
backgroundColor: dark ? '#111111' : sepia ? '#f4ecd8' : '#ffffff',
|
|
273
|
+
fontSize: appearance.fontScale,
|
|
274
|
+
lineHeight: appearance.lineHeight,
|
|
275
|
+
textColor: dark ? '#f5f5f5' : sepia ? '#3f3527' : '#111111',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parseInitialLocator(value: string | undefined): Locator | undefined {
|
|
280
|
+
if (!value) return undefined;
|
|
281
|
+
try {
|
|
282
|
+
return Locator.deserialize(JSON.parse(value));
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function attachFrameLinkPolicy(window: Window, onExternalLink: (url: string) => void): void {
|
|
289
|
+
window.document.addEventListener('click', (event) => {
|
|
290
|
+
const target = event.target instanceof Element ? event.target.closest('a') : null;
|
|
291
|
+
const externalUrl = target?.getAttribute('data-reader-external-href');
|
|
292
|
+
if (!externalUrl) return;
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
event.stopPropagation();
|
|
295
|
+
onExternalLink(externalUrl);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function navigate(start: (done: (ok: boolean) => void) => void): Promise<void> {
|
|
300
|
+
return new Promise((resolve) => start(() => resolve()));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function shouldAnimateNavigation(): boolean {
|
|
304
|
+
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function noop(): void {
|
|
308
|
+
// Readium requires a complete listener object; these events are outside the reader contract.
|
|
309
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDocument,
|
|
3
|
+
InvalidPDFException,
|
|
4
|
+
PasswordException,
|
|
5
|
+
type PDFDocumentLoadingTask,
|
|
6
|
+
type PDFDocumentProxy,
|
|
7
|
+
PDFWorker,
|
|
8
|
+
RenderingCancelledException,
|
|
9
|
+
type RenderTask,
|
|
10
|
+
TextLayer,
|
|
11
|
+
} from 'pdfjs-dist';
|
|
12
|
+
|
|
13
|
+
import { ReaderDocumentError } from '../ReaderDocumentError';
|
|
14
|
+
import type { ReaderDriver, ReaderDriverState } from '../types';
|
|
15
|
+
|
|
16
|
+
export class PdfReaderDriver implements ReaderDriver {
|
|
17
|
+
readonly direction = 'ltr' as const;
|
|
18
|
+
readonly #container: HTMLElement;
|
|
19
|
+
readonly #document: PDFDocumentProxy;
|
|
20
|
+
readonly #loadingTask: PDFDocumentLoadingTask;
|
|
21
|
+
readonly #onExternalLink: (url: string) => void;
|
|
22
|
+
readonly #onRenderError: (error: unknown) => void;
|
|
23
|
+
readonly #worker: PDFWorker;
|
|
24
|
+
#page: number;
|
|
25
|
+
#renderTask?: RenderTask;
|
|
26
|
+
#resizeFrame?: number;
|
|
27
|
+
#resizeObserver?: ResizeObserver;
|
|
28
|
+
#textLayer?: TextLayer;
|
|
29
|
+
|
|
30
|
+
private constructor(args: {
|
|
31
|
+
container: HTMLElement;
|
|
32
|
+
document: PDFDocumentProxy;
|
|
33
|
+
initialPage: number;
|
|
34
|
+
loadingTask: PDFDocumentLoadingTask;
|
|
35
|
+
onExternalLink: (url: string) => void;
|
|
36
|
+
onRenderError: (error: unknown) => void;
|
|
37
|
+
worker: PDFWorker;
|
|
38
|
+
}) {
|
|
39
|
+
this.#container = args.container;
|
|
40
|
+
this.#document = args.document;
|
|
41
|
+
this.#loadingTask = args.loadingTask;
|
|
42
|
+
this.#onExternalLink = args.onExternalLink;
|
|
43
|
+
this.#onRenderError = args.onRenderError;
|
|
44
|
+
this.#worker = args.worker;
|
|
45
|
+
this.#page = args.initialPage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static async create(args: {
|
|
49
|
+
bytes: Uint8Array;
|
|
50
|
+
container: HTMLElement;
|
|
51
|
+
initialLocation?: string;
|
|
52
|
+
onExternalLink: (url: string) => void;
|
|
53
|
+
onRenderError: (error: unknown) => void;
|
|
54
|
+
}): Promise<PdfReaderDriver> {
|
|
55
|
+
const worker = createPdfWorker();
|
|
56
|
+
const loadingTask = getDocument({
|
|
57
|
+
data: args.bytes,
|
|
58
|
+
stopAtErrors: true,
|
|
59
|
+
worker,
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
const document = await loadingTask.promise;
|
|
63
|
+
const driver = new PdfReaderDriver({
|
|
64
|
+
container: args.container,
|
|
65
|
+
document,
|
|
66
|
+
initialPage: resolveInitialPage(args.initialLocation, document.numPages),
|
|
67
|
+
loadingTask,
|
|
68
|
+
onExternalLink: args.onExternalLink,
|
|
69
|
+
onRenderError: args.onRenderError,
|
|
70
|
+
worker,
|
|
71
|
+
});
|
|
72
|
+
await driver.#renderPage();
|
|
73
|
+
driver.#observeResize();
|
|
74
|
+
return driver;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
await loadingTask.destroy();
|
|
77
|
+
worker.destroy();
|
|
78
|
+
if (error instanceof PasswordException) {
|
|
79
|
+
throw new ReaderDocumentError(
|
|
80
|
+
'protected-document',
|
|
81
|
+
'This PDF is password protected and cannot be opened.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof InvalidPDFException) {
|
|
85
|
+
throw new ReaderDocumentError('invalid-document', 'The PDF document is invalid.');
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getState(): ReaderDriverState {
|
|
92
|
+
return {
|
|
93
|
+
canGoNext: this.#page < this.#document.numPages,
|
|
94
|
+
canGoPrevious: this.#page > 1,
|
|
95
|
+
locator: `page=${this.#page}`,
|
|
96
|
+
page: this.#page,
|
|
97
|
+
pageCount: this.#document.numPages,
|
|
98
|
+
progression:
|
|
99
|
+
this.#document.numPages <= 1 ? 1 : (this.#page - 1) / (this.#document.numPages - 1),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async goNext(): Promise<void> {
|
|
104
|
+
if (this.#page >= this.#document.numPages) return;
|
|
105
|
+
this.#page += 1;
|
|
106
|
+
await this.#renderPage();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async goPrevious(): Promise<void> {
|
|
110
|
+
if (this.#page <= 1) return;
|
|
111
|
+
this.#page -= 1;
|
|
112
|
+
await this.#renderPage();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async destroy(): Promise<void> {
|
|
116
|
+
this.#renderTask?.cancel();
|
|
117
|
+
this.#resizeObserver?.disconnect();
|
|
118
|
+
if (this.#resizeFrame !== undefined) cancelAnimationFrame(this.#resizeFrame);
|
|
119
|
+
this.#textLayer?.cancel();
|
|
120
|
+
TextLayer.cleanup();
|
|
121
|
+
await this.#loadingTask.destroy();
|
|
122
|
+
this.#worker.destroy();
|
|
123
|
+
this.#container.replaceChildren();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async #renderPage(): Promise<void> {
|
|
127
|
+
this.#renderTask?.cancel();
|
|
128
|
+
this.#textLayer?.cancel();
|
|
129
|
+
const page = await this.#document.getPage(this.#page);
|
|
130
|
+
const baseViewport = page.getViewport({ scale: 1 });
|
|
131
|
+
const availableWidth = Math.max(1, this.#container.clientWidth);
|
|
132
|
+
const viewport = page.getViewport({ scale: availableWidth / baseViewport.width });
|
|
133
|
+
const pageElement = document.createElement('div');
|
|
134
|
+
const canvas = document.createElement('canvas');
|
|
135
|
+
const textContainer = document.createElement('div');
|
|
136
|
+
const linkContainer = document.createElement('div');
|
|
137
|
+
const outputScale = window.devicePixelRatio || 1;
|
|
138
|
+
|
|
139
|
+
pageElement.className = 'reader-pdf-page';
|
|
140
|
+
pageElement.style.setProperty('--scale-factor', String(viewport.scale));
|
|
141
|
+
pageElement.style.width = `${viewport.width}px`;
|
|
142
|
+
pageElement.style.height = `${viewport.height}px`;
|
|
143
|
+
canvas.width = Math.floor(viewport.width * outputScale);
|
|
144
|
+
canvas.height = Math.floor(viewport.height * outputScale);
|
|
145
|
+
canvas.style.width = `${viewport.width}px`;
|
|
146
|
+
canvas.style.height = `${viewport.height}px`;
|
|
147
|
+
textContainer.className = 'reader-pdf-text-layer';
|
|
148
|
+
textContainer.style.width = `${viewport.width}px`;
|
|
149
|
+
textContainer.style.height = `${viewport.height}px`;
|
|
150
|
+
linkContainer.className = 'reader-pdf-link-layer';
|
|
151
|
+
pageElement.append(canvas, textContainer, linkContainer);
|
|
152
|
+
this.#container.replaceChildren(pageElement);
|
|
153
|
+
|
|
154
|
+
const context = canvas.getContext('2d');
|
|
155
|
+
if (context === null) throw new Error('The PDF canvas context is unavailable.');
|
|
156
|
+
this.#renderTask = page.render({
|
|
157
|
+
canvas,
|
|
158
|
+
canvasContext: context,
|
|
159
|
+
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
|
|
160
|
+
viewport,
|
|
161
|
+
});
|
|
162
|
+
this.#textLayer = new TextLayer({
|
|
163
|
+
container: textContainer,
|
|
164
|
+
textContentSource: page.streamTextContent(),
|
|
165
|
+
viewport,
|
|
166
|
+
});
|
|
167
|
+
await Promise.all([this.#renderTask.promise, this.#textLayer.render()]);
|
|
168
|
+
await renderExternalLinks(page, viewport, linkContainer, this.#onExternalLink);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#observeResize(): void {
|
|
172
|
+
let previousWidth = this.#container.clientWidth;
|
|
173
|
+
this.#resizeObserver = new ResizeObserver(() => {
|
|
174
|
+
const width = this.#container.clientWidth;
|
|
175
|
+
if (width === previousWidth) return;
|
|
176
|
+
previousWidth = width;
|
|
177
|
+
if (this.#resizeFrame !== undefined) cancelAnimationFrame(this.#resizeFrame);
|
|
178
|
+
this.#resizeFrame = requestAnimationFrame(() => {
|
|
179
|
+
void this.#renderPage().catch((error: unknown) => {
|
|
180
|
+
if (!(error instanceof RenderingCancelledException)) this.#onRenderError(error);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
this.#resizeObserver.observe(this.#container);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createPdfWorker(): PDFWorker {
|
|
189
|
+
const port = new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), {
|
|
190
|
+
type: 'module',
|
|
191
|
+
});
|
|
192
|
+
return PDFWorker.create({ port });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function renderExternalLinks(
|
|
196
|
+
page: Awaited<ReturnType<PDFDocumentProxy['getPage']>>,
|
|
197
|
+
viewport: PdfViewport,
|
|
198
|
+
container: HTMLElement,
|
|
199
|
+
onExternalLink: (url: string) => void,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const annotations: unknown = await page.getAnnotations({ intent: 'display' });
|
|
202
|
+
if (!Array.isArray(annotations)) return;
|
|
203
|
+
for (const annotation of annotations as unknown[]) {
|
|
204
|
+
if (!isExternalPdfLink(annotation)) continue;
|
|
205
|
+
const [left, top] = viewport.convertToViewportPoint(annotation.rect[0], annotation.rect[1]) as [
|
|
206
|
+
number,
|
|
207
|
+
number,
|
|
208
|
+
];
|
|
209
|
+
const [right, bottom] = viewport.convertToViewportPoint(
|
|
210
|
+
annotation.rect[2],
|
|
211
|
+
annotation.rect[3],
|
|
212
|
+
) as [number, number];
|
|
213
|
+
const link = document.createElement('a');
|
|
214
|
+
link.setAttribute('aria-label', annotation.titleObj?.str ?? annotation.url);
|
|
215
|
+
link.href = '#';
|
|
216
|
+
link.style.inset = `${Math.min(bottom, top)}px auto auto ${Math.min(left, right)}px`;
|
|
217
|
+
link.style.width = `${Math.abs(right - left)}px`;
|
|
218
|
+
link.style.height = `${Math.abs(top - bottom)}px`;
|
|
219
|
+
link.addEventListener('click', (event) => {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
onExternalLink(annotation.url);
|
|
222
|
+
});
|
|
223
|
+
container.append(link);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface PdfViewport {
|
|
228
|
+
convertToViewportPoint(x: number, y: number): number[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isExternalPdfLink(annotation: unknown): annotation is Record<string, unknown> & {
|
|
232
|
+
rect: [number, number, number, number];
|
|
233
|
+
titleObj?: { str?: string };
|
|
234
|
+
url: string;
|
|
235
|
+
} {
|
|
236
|
+
return (
|
|
237
|
+
typeof annotation === 'object' &&
|
|
238
|
+
annotation !== null &&
|
|
239
|
+
'url' in annotation &&
|
|
240
|
+
'rect' in annotation &&
|
|
241
|
+
typeof annotation.url === 'string' &&
|
|
242
|
+
/^(?:https?:|mailto:|tel:)/i.test(annotation.url) &&
|
|
243
|
+
Array.isArray(annotation.rect) &&
|
|
244
|
+
annotation.rect.length === 4 &&
|
|
245
|
+
annotation.rect.every((value) => typeof value === 'number')
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveInitialPage(location: string | undefined, pageCount: number): number {
|
|
250
|
+
const match = /^page=(\d+)$/.exec(location ?? '');
|
|
251
|
+
const page = Number(match?.[1] ?? 1);
|
|
252
|
+
return Math.max(1, Math.min(pageCount, Number.isSafeInteger(page) ? page : 1));
|
|
253
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Entry,
|
|
3
|
+
type FileEntry,
|
|
4
|
+
Uint8ArrayReader,
|
|
5
|
+
Uint8ArrayWriter,
|
|
6
|
+
ZipReader,
|
|
7
|
+
} from '@zip.js/zip.js';
|
|
8
|
+
|
|
9
|
+
import { ReaderDocumentError } from '../ReaderDocumentError';
|
|
10
|
+
import { validateEpubArchiveEntriesAsync } from '../validateEpubArchiveEntriesAsync';
|
|
11
|
+
import { normalizeArchivePath } from './path';
|
|
12
|
+
|
|
13
|
+
export class EpubArchive {
|
|
14
|
+
readonly #entries: Map<string, FileEntry>;
|
|
15
|
+
readonly #reader: ZipReader<Uint8Array>;
|
|
16
|
+
|
|
17
|
+
private constructor(reader: ZipReader<Uint8Array>, entries: readonly Entry[]) {
|
|
18
|
+
this.#reader = reader;
|
|
19
|
+
this.#entries = createEntryMap(entries);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async open(bytes: Uint8Array, signal: AbortSignal): Promise<EpubArchive> {
|
|
23
|
+
const reader = new ZipReader(new Uint8ArrayReader(bytes), { strictness: 'strict' });
|
|
24
|
+
try {
|
|
25
|
+
const entries = await reader.getEntries({ strictness: 'strict' });
|
|
26
|
+
await validateEpubArchiveEntriesAsync(entries, signal);
|
|
27
|
+
if (entries.some((entry) => !entry.directory && entry.encrypted)) {
|
|
28
|
+
throw new ReaderDocumentError(
|
|
29
|
+
'protected-document',
|
|
30
|
+
'This EPUB uses content protection and cannot be opened.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return new EpubArchive(reader, entries);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await reader.close();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
paths(): readonly string[] {
|
|
41
|
+
return [...this.#entries.keys()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
has(path: string): boolean {
|
|
45
|
+
return this.#entries.has(normalizeArchivePath(path));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async read(path: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
49
|
+
const normalizedPath = normalizeArchivePath(path);
|
|
50
|
+
const entry = this.#entries.get(normalizedPath);
|
|
51
|
+
if (entry === undefined) throw new Error(`Missing EPUB resource: ${normalizedPath}`);
|
|
52
|
+
return entry.getData(new Uint8ArrayWriter(), {
|
|
53
|
+
checkCrc32: true,
|
|
54
|
+
checkOverlappingEntry: true,
|
|
55
|
+
signal,
|
|
56
|
+
strictness: 'strict',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async readText(path: string, signal?: AbortSignal): Promise<string> {
|
|
61
|
+
return new TextDecoder().decode(await this.read(path, signal));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async close(): Promise<void> {
|
|
65
|
+
await this.#reader.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createEntryMap(entries: readonly Entry[]): Map<string, FileEntry> {
|
|
70
|
+
const files = new Map<string, FileEntry>();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.directory) continue;
|
|
73
|
+
const normalizedPath = normalizeArchivePath(entry.filename);
|
|
74
|
+
if (files.has(normalizedPath)) {
|
|
75
|
+
throw new ReaderDocumentError(
|
|
76
|
+
'invalid-document',
|
|
77
|
+
`The EPUB contains duplicate resource path "${normalizedPath}".`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
files.set(normalizedPath, entry);
|
|
81
|
+
}
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Link } from '@readium/shared';
|
|
2
|
+
import { type Fetcher, Resource } from '@readium/shared';
|
|
3
|
+
|
|
4
|
+
import type { EpubArchive } from './EpubArchive';
|
|
5
|
+
|
|
6
|
+
export class EpubArchiveFetcher implements Fetcher {
|
|
7
|
+
readonly #archive: EpubArchive;
|
|
8
|
+
readonly #links: Link[];
|
|
9
|
+
readonly #overrides: ReadonlyMap<string, Uint8Array>;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
archive: EpubArchive,
|
|
13
|
+
links: readonly Link[],
|
|
14
|
+
overrides: ReadonlyMap<string, Uint8Array>,
|
|
15
|
+
) {
|
|
16
|
+
this.#archive = archive;
|
|
17
|
+
this.#links = [...links];
|
|
18
|
+
this.#overrides = overrides;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
links(): Link[] {
|
|
22
|
+
return [...this.#links];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(link: Link): Resource {
|
|
26
|
+
return new EpubArchiveResource(this.#archive, link, this.#overrides.get(link.href));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
close(): void {
|
|
30
|
+
void this.#archive.close();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class EpubArchiveResource extends Resource {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly archive: EpubArchive,
|
|
37
|
+
readonly resourceLink: Link,
|
|
38
|
+
readonly override: Uint8Array | undefined,
|
|
39
|
+
) {
|
|
40
|
+
super();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
link(): Promise<Link> {
|
|
44
|
+
return Promise.resolve(this.resourceLink);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async length(): Promise<number | undefined> {
|
|
48
|
+
return (this.override ?? (await this.archive.read(this.resourceLink.href))).byteLength;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async read(): Promise<Uint8Array | undefined> {
|
|
52
|
+
return this.override ?? this.archive.read(this.resourceLink.href);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
close(): void {
|
|
56
|
+
// Archive lifetime is owned by the driver.
|
|
57
|
+
}
|
|
58
|
+
}
|