@ankhorage/expo-runtime 3.1.1 → 3.2.0
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 +12 -0
- package/README.md +2 -1
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.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/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 +11 -4
- package/src/createExpoRuntimeRegistry.test.tsx +9 -1
- package/src/createExpoRuntimeRegistry.tsx +7 -1
- package/src/expoRuntimePlanningMetadata.ts +9 -1
- package/src/index.ts +1 -0
- 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/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,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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const HTML_MEDIA_TYPES = new Set(['application/xhtml+xml', 'text/html']);
|
|
2
|
+
|
|
3
|
+
export function inferEpubMediaType(path: string): string {
|
|
4
|
+
const extension = path.split('.').pop()?.toLowerCase() ?? '';
|
|
5
|
+
if (extension === 'css') return 'text/css';
|
|
6
|
+
if (extension === 'gif') return 'image/gif';
|
|
7
|
+
if (extension === 'html') return 'text/html';
|
|
8
|
+
if (extension === 'jpeg' || extension === 'jpg') return 'image/jpeg';
|
|
9
|
+
if (extension === 'png') return 'image/png';
|
|
10
|
+
if (extension === 'svg') return 'image/svg+xml';
|
|
11
|
+
if (extension === 'ttf') return 'font/ttf';
|
|
12
|
+
if (extension === 'woff') return 'font/woff';
|
|
13
|
+
if (extension === 'woff2') return 'font/woff2';
|
|
14
|
+
if (extension === 'xhtml') return 'application/xhtml+xml';
|
|
15
|
+
return 'application/octet-stream';
|
|
16
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { DOMParser as XmlDomParser } from '@xmldom/xmldom';
|
|
2
|
+
import { TextReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { EpubArchive } from './EpubArchive';
|
|
6
|
+
import { parseEpubPackage } from './parseEpubPackage';
|
|
7
|
+
|
|
8
|
+
const browserDomParser = globalThis.DOMParser;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
globalThis.DOMParser = XmlDomParser as unknown as typeof DOMParser;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
globalThis.DOMParser = browserDomParser;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('parseEpubPackage deterministic EPUB fixtures', () => {
|
|
19
|
+
test('parses a reflowable EPUB 3 spine and resources', async () => {
|
|
20
|
+
const archive = await openFixture(
|
|
21
|
+
packageDocument({
|
|
22
|
+
metadata: '',
|
|
23
|
+
progression: 'default',
|
|
24
|
+
spineProperties: '',
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
const parsed = await parseEpubPackage(archive, new AbortController().signal);
|
|
28
|
+
|
|
29
|
+
expect(parsed.fixedLayout).toBe(false);
|
|
30
|
+
expect(parsed.direction).toBe('ltr');
|
|
31
|
+
expect(parsed.readingOrder.map((item) => item.href)).toEqual([
|
|
32
|
+
'OPS/chapter-1.xhtml',
|
|
33
|
+
'OPS/chapter-2.xhtml',
|
|
34
|
+
]);
|
|
35
|
+
expect(parsed.resources.map((item) => item.href)).toContain('OPS/nav.xhtml');
|
|
36
|
+
expect(parsed.tableOfContents).toEqual([
|
|
37
|
+
{ href: 'OPS/chapter-1.xhtml#start', title: 'Chapter one' },
|
|
38
|
+
]);
|
|
39
|
+
await archive.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('preserves fixed-layout and RTL package metadata', async () => {
|
|
43
|
+
const archive = await openFixture(
|
|
44
|
+
packageDocument({
|
|
45
|
+
metadata: '<meta property="rendition:layout">pre-paginated</meta>',
|
|
46
|
+
progression: 'rtl',
|
|
47
|
+
spineProperties: 'properties="rendition:layout-pre-paginated"',
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const parsed = await parseEpubPackage(archive, new AbortController().signal);
|
|
51
|
+
|
|
52
|
+
expect(parsed.fixedLayout).toBe(true);
|
|
53
|
+
expect(parsed.direction).toBe('rtl');
|
|
54
|
+
await archive.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('rejects encrypted publication resources as protected content', async () => {
|
|
58
|
+
const archive = await openFixture(packageDocument(), {
|
|
59
|
+
'META-INF/encryption.xml': '<?xml version="1.0"?><encryption><EncryptedData /></encryption>',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let error: unknown;
|
|
63
|
+
try {
|
|
64
|
+
await parseEpubPackage(archive, new AbortController().signal);
|
|
65
|
+
} catch (caught) {
|
|
66
|
+
error = caught;
|
|
67
|
+
}
|
|
68
|
+
expect(error).toMatchObject({ code: 'protected-document' });
|
|
69
|
+
await archive.close();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function openFixture(
|
|
74
|
+
packageSource: string,
|
|
75
|
+
extraFiles: Readonly<Record<string, string>> = {},
|
|
76
|
+
): Promise<EpubArchive> {
|
|
77
|
+
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
78
|
+
const files = {
|
|
79
|
+
mimetype: 'application/epub+zip',
|
|
80
|
+
'META-INF/container.xml':
|
|
81
|
+
'<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/package.opf" /></rootfiles></container>',
|
|
82
|
+
'OPS/package.opf': packageSource,
|
|
83
|
+
'OPS/chapter-1.xhtml': '<html><body><h1>One</h1></body></html>',
|
|
84
|
+
'OPS/chapter-2.xhtml': '<html><body><h1>Two</h1></body></html>',
|
|
85
|
+
'OPS/nav.xhtml':
|
|
86
|
+
'<html xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><a href="chapter-1.xhtml#start">Chapter one</a></nav></body></html>',
|
|
87
|
+
...extraFiles,
|
|
88
|
+
};
|
|
89
|
+
for (const [path, content] of Object.entries(files)) {
|
|
90
|
+
await writer.add(path, new TextReader(content), { lastModDate: new Date(0) });
|
|
91
|
+
}
|
|
92
|
+
const bytes = await writer.close();
|
|
93
|
+
return EpubArchive.open(bytes, new AbortController().signal);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function packageDocument(
|
|
97
|
+
options: {
|
|
98
|
+
metadata?: string;
|
|
99
|
+
progression?: 'default' | 'rtl';
|
|
100
|
+
spineProperties?: string;
|
|
101
|
+
} = {},
|
|
102
|
+
): string {
|
|
103
|
+
return `<?xml version="1.0"?>
|
|
104
|
+
<package version="3.0">
|
|
105
|
+
<metadata><title>Reader fixture</title><language>en</language>${options.metadata ?? ''}</metadata>
|
|
106
|
+
<manifest>
|
|
107
|
+
<item id="one" href="chapter-1.xhtml" media-type="application/xhtml+xml" />
|
|
108
|
+
<item id="two" href="chapter-2.xhtml" media-type="application/xhtml+xml" />
|
|
109
|
+
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
|
|
110
|
+
</manifest>
|
|
111
|
+
<spine page-progression-direction="${options.progression ?? 'default'}">
|
|
112
|
+
<itemref idref="one" ${options.spineProperties ?? ''} />
|
|
113
|
+
<itemref idref="two" ${options.spineProperties ?? ''} />
|
|
114
|
+
</spine>
|
|
115
|
+
</package>`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* eslint-disable max-lines-per-function, complexity -- Package parsing validates one transactional document graph. */
|
|
2
|
+
import { ReaderDocumentError } from '../ReaderDocumentError';
|
|
3
|
+
import type { EpubArchive } from './EpubArchive';
|
|
4
|
+
import { resolveArchivePath } from './path';
|
|
5
|
+
|
|
6
|
+
interface EpubPackageItem {
|
|
7
|
+
readonly href: string;
|
|
8
|
+
readonly mediaType: string;
|
|
9
|
+
readonly properties: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ParsedEpubPackage {
|
|
13
|
+
readonly direction: 'ltr' | 'rtl';
|
|
14
|
+
readonly fixedLayout: boolean;
|
|
15
|
+
readonly language?: string;
|
|
16
|
+
readonly packagePath: string;
|
|
17
|
+
readonly readingOrder: readonly EpubPackageItem[];
|
|
18
|
+
readonly resources: readonly EpubPackageItem[];
|
|
19
|
+
readonly tableOfContents: readonly { readonly href: string; readonly title: string }[];
|
|
20
|
+
readonly title: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function parseEpubPackage(
|
|
24
|
+
archive: EpubArchive,
|
|
25
|
+
signal: AbortSignal,
|
|
26
|
+
): Promise<ParsedEpubPackage> {
|
|
27
|
+
const mimetype = (await archive.readText('mimetype', signal)).trim();
|
|
28
|
+
if (mimetype !== 'application/epub+zip') {
|
|
29
|
+
throw new ReaderDocumentError('invalid-document', 'The archive is not an EPUB document.');
|
|
30
|
+
}
|
|
31
|
+
if (archive.has('META-INF/encryption.xml')) {
|
|
32
|
+
const encryption = await archive.readText('META-INF/encryption.xml', signal);
|
|
33
|
+
if (
|
|
34
|
+
elementsByLocalName(parseXml(encryption, 'EPUB encryption metadata'), 'EncryptedData')
|
|
35
|
+
.length > 0
|
|
36
|
+
) {
|
|
37
|
+
throw new ReaderDocumentError(
|
|
38
|
+
'protected-document',
|
|
39
|
+
'This EPUB uses content protection and cannot be opened.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const container = parseXml(
|
|
45
|
+
await archive.readText('META-INF/container.xml', signal),
|
|
46
|
+
'EPUB container',
|
|
47
|
+
);
|
|
48
|
+
const [rootfile] = elementsByLocalName(container, 'rootfile');
|
|
49
|
+
const packagePathValue = rootfile?.getAttribute('full-path');
|
|
50
|
+
if (!packagePathValue) {
|
|
51
|
+
throw new ReaderDocumentError(
|
|
52
|
+
'invalid-document',
|
|
53
|
+
'The EPUB container has no package document.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const packagePath = resolveArchivePath('', packagePathValue);
|
|
57
|
+
const packageDocument = parseXml(await archive.readText(packagePath, signal), 'EPUB package');
|
|
58
|
+
|
|
59
|
+
const manifestItems = new Map<string, EpubPackageItem>();
|
|
60
|
+
for (const item of elementsByLocalName(packageDocument, 'item')) {
|
|
61
|
+
const id = item.getAttribute('id');
|
|
62
|
+
const href = item.getAttribute('href');
|
|
63
|
+
const mediaType = item.getAttribute('media-type');
|
|
64
|
+
if (!id || !href || !mediaType) continue;
|
|
65
|
+
manifestItems.set(id, {
|
|
66
|
+
href: resolveArchivePath(packagePath, href),
|
|
67
|
+
mediaType,
|
|
68
|
+
properties: (item.getAttribute('properties') ?? '').split(/\s+/).filter(Boolean),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [spine] = elementsByLocalName(packageDocument, 'spine');
|
|
73
|
+
const readingOrder = elementsByLocalName(spine ?? packageDocument, 'itemref')
|
|
74
|
+
.map((itemref) => manifestItems.get(itemref.getAttribute('idref') ?? ''))
|
|
75
|
+
.filter((item): item is EpubPackageItem => item !== undefined);
|
|
76
|
+
if (readingOrder.length === 0) {
|
|
77
|
+
throw new ReaderDocumentError('invalid-document', 'The EPUB package has no readable spine.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const metadata =
|
|
81
|
+
elementsByLocalName(packageDocument, 'metadata')[0] ?? packageDocument.documentElement;
|
|
82
|
+
const title = elementText(metadata, 'title') ?? 'Untitled publication';
|
|
83
|
+
const language = elementText(metadata, 'language');
|
|
84
|
+
const fixedLayout =
|
|
85
|
+
elementsByLocalName(metadata, 'meta').some(
|
|
86
|
+
(element) =>
|
|
87
|
+
element.getAttribute('property') === 'rendition:layout' &&
|
|
88
|
+
element.textContent.trim() === 'pre-paginated',
|
|
89
|
+
) || readingOrder.every((item) => item.properties.includes('rendition:layout-pre-paginated'));
|
|
90
|
+
const direction = spine?.getAttribute('page-progression-direction') === 'rtl' ? 'rtl' : 'ltr';
|
|
91
|
+
const readingPaths = new Set(readingOrder.map((item) => item.href));
|
|
92
|
+
const navigationItem = [...manifestItems.values()].find((item) =>
|
|
93
|
+
item.properties.includes('nav'),
|
|
94
|
+
);
|
|
95
|
+
const tableOfContents = navigationItem
|
|
96
|
+
? await parseNavigationDocument(archive, navigationItem.href, signal)
|
|
97
|
+
: [];
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
direction,
|
|
101
|
+
fixedLayout,
|
|
102
|
+
language,
|
|
103
|
+
packagePath,
|
|
104
|
+
readingOrder,
|
|
105
|
+
resources: [...manifestItems.values()].filter((item) => !readingPaths.has(item.href)),
|
|
106
|
+
tableOfContents,
|
|
107
|
+
title,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function parseNavigationDocument(
|
|
112
|
+
archive: EpubArchive,
|
|
113
|
+
navigationPath: string,
|
|
114
|
+
signal: AbortSignal,
|
|
115
|
+
): Promise<{ href: string; title: string }[]> {
|
|
116
|
+
const document = parseXml(await archive.readText(navigationPath, signal), 'EPUB navigation');
|
|
117
|
+
const navigation =
|
|
118
|
+
elementsByLocalName(document, 'nav').find((element) =>
|
|
119
|
+
(element.getAttribute('epub:type') ?? element.getAttribute('type') ?? '')
|
|
120
|
+
.split(/\s+/)
|
|
121
|
+
.includes('toc'),
|
|
122
|
+
) ?? elementsByLocalName(document, 'nav')[0];
|
|
123
|
+
if (!navigation) return [];
|
|
124
|
+
return elementsByLocalName(navigation, 'a').flatMap((anchor) => {
|
|
125
|
+
const href = anchor.getAttribute('href');
|
|
126
|
+
const title = anchor.textContent.trim();
|
|
127
|
+
if (!href || !title) return [];
|
|
128
|
+
const [resourceReference, fragment] = href.split('#', 2);
|
|
129
|
+
const resourcePath = resolveArchivePath(navigationPath, resourceReference ?? '');
|
|
130
|
+
return [{ href: fragment ? `${resourcePath}#${fragment}` : resourcePath, title }];
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseXml(source: string, label: string): XMLDocument {
|
|
135
|
+
const document = new DOMParser().parseFromString(source, 'application/xml');
|
|
136
|
+
if (elementsByLocalName(document, 'parsererror').length > 0) {
|
|
137
|
+
throw new ReaderDocumentError('invalid-document', `Invalid ${label}.`);
|
|
138
|
+
}
|
|
139
|
+
return document;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function elementsByLocalName(root: Document | Element, localName: string): Element[] {
|
|
143
|
+
return Array.from(root.getElementsByTagName('*')).filter(
|
|
144
|
+
(element) => element.localName === localName,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function elementText(root: Document | Element, localName: string): string | undefined {
|
|
149
|
+
const [element] = elementsByLocalName(root, localName);
|
|
150
|
+
const value = element?.textContent.trim();
|
|
151
|
+
return value === '' ? undefined : value;
|
|
152
|
+
}
|