@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,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { isExternalReference, normalizeArchivePath, resolveArchivePath } from './path';
|
|
4
|
+
|
|
5
|
+
describe('EPUB archive paths', () => {
|
|
6
|
+
test('resolves package-relative resources without escaping the archive', () => {
|
|
7
|
+
expect(resolveArchivePath('OPS/package.opf', 'text/../images/cover.jpg')).toBe(
|
|
8
|
+
'OPS/images/cover.jpg',
|
|
9
|
+
);
|
|
10
|
+
expect(() => resolveArchivePath('', '../../outside')).toThrow('escapes the archive');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('normalizes platform separators and encoded names', () => {
|
|
14
|
+
expect(normalizeArchivePath('OPS\\Text%20One.xhtml')).toBe('OPS/Text One.xhtml');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('classifies active external URL schemes', () => {
|
|
18
|
+
expect(isExternalReference('https://example.test')).toBe(true);
|
|
19
|
+
expect(isExternalReference('javascript:alert(1)')).toBe(true);
|
|
20
|
+
expect(isExternalReference('../images/cover.png')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function normalizeArchivePath(path: string): string {
|
|
2
|
+
const segments: string[] = [];
|
|
3
|
+
for (const segment of decodeURIComponent(path).replaceAll('\\', '/').split('/')) {
|
|
4
|
+
if (segment === '' || segment === '.') continue;
|
|
5
|
+
if (segment === '..') {
|
|
6
|
+
if (segments.length === 0) throw new Error(`EPUB resource escapes the archive: ${path}`);
|
|
7
|
+
segments.pop();
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
segments.push(segment);
|
|
11
|
+
}
|
|
12
|
+
return segments.join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveArchivePath(basePath: string, reference: string): string {
|
|
16
|
+
const cleanReference = reference.split('#', 1)[0]?.split('?', 1)[0] ?? '';
|
|
17
|
+
if (cleanReference.startsWith('/')) return normalizeArchivePath(cleanReference);
|
|
18
|
+
const baseDirectory = basePath.includes('/')
|
|
19
|
+
? basePath.slice(0, basePath.lastIndexOf('/') + 1)
|
|
20
|
+
: '';
|
|
21
|
+
return normalizeArchivePath(`${baseDirectory}${cleanReference}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isExternalReference(reference: string): boolean {
|
|
25
|
+
return /^(?:https?:|mailto:|tel:|data:|javascript:|\/\/)/i.test(reference.trim());
|
|
26
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isExternalReference, resolveArchivePath } from './path';
|
|
2
|
+
|
|
3
|
+
const REMOVED_ELEMENTS = 'script, form, iframe, frame, object, embed';
|
|
4
|
+
const RESOURCE_ATTRIBUTES = ['src', 'poster', 'data'] as const;
|
|
5
|
+
|
|
6
|
+
export function sanitizeEpubDocument(
|
|
7
|
+
source: string,
|
|
8
|
+
resourcePath: string,
|
|
9
|
+
resolveResourceUrl: (path: string) => string | undefined,
|
|
10
|
+
): string {
|
|
11
|
+
const document = new DOMParser().parseFromString(source, 'application/xhtml+xml');
|
|
12
|
+
if (document.querySelector('parsererror')) {
|
|
13
|
+
throw new Error(`Invalid EPUB XHTML resource: ${resourcePath}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
document.querySelectorAll(REMOVED_ELEMENTS).forEach((element) => element.remove());
|
|
17
|
+
document
|
|
18
|
+
.querySelectorAll('meta[http-equiv]')
|
|
19
|
+
.forEach(
|
|
20
|
+
(element) =>
|
|
21
|
+
element.getAttribute('http-equiv')?.toLowerCase() === 'refresh' && element.remove(),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
sanitizeElementAttributes(document, resourcePath, resolveResourceUrl);
|
|
25
|
+
rewriteDocumentResources(document, resourcePath, resolveResourceUrl);
|
|
26
|
+
rewriteDocumentLinks(document);
|
|
27
|
+
return new XMLSerializer().serializeToString(document);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sanitizeElementAttributes(
|
|
31
|
+
document: XMLDocument,
|
|
32
|
+
resourcePath: string,
|
|
33
|
+
resolveResourceUrl: (path: string) => string | undefined,
|
|
34
|
+
): void {
|
|
35
|
+
for (const element of document.querySelectorAll('*')) {
|
|
36
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
37
|
+
if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);
|
|
38
|
+
}
|
|
39
|
+
const inlineStyle = element.getAttribute('style');
|
|
40
|
+
if (inlineStyle) {
|
|
41
|
+
element.setAttribute(
|
|
42
|
+
'style',
|
|
43
|
+
sanitizeEpubStylesheet(inlineStyle, resourcePath, resolveResourceUrl),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
element.removeAttribute('srcset');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const style of document.querySelectorAll('style')) {
|
|
50
|
+
style.textContent = sanitizeEpubStylesheet(style.textContent, resourcePath, resolveResourceUrl);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rewriteDocumentResources(
|
|
55
|
+
document: XMLDocument,
|
|
56
|
+
resourcePath: string,
|
|
57
|
+
resolveResourceUrl: (path: string) => string | undefined,
|
|
58
|
+
): void {
|
|
59
|
+
for (const attributeName of RESOURCE_ATTRIBUTES) {
|
|
60
|
+
for (const element of document.querySelectorAll(`[${attributeName}]`)) {
|
|
61
|
+
rewriteResourceAttribute(element, attributeName, resourcePath, resolveResourceUrl);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const link of document.querySelectorAll('link[href]')) {
|
|
66
|
+
rewriteResourceAttribute(link, 'href', resourcePath, resolveResourceUrl);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rewriteDocumentLinks(document: XMLDocument): void {
|
|
71
|
+
for (const anchor of document.querySelectorAll('a[href]')) {
|
|
72
|
+
const href = anchor.getAttribute('href')?.trim();
|
|
73
|
+
if (!href) continue;
|
|
74
|
+
if (/^(?:https?:|mailto:|tel:|\/\/)/i.test(href)) {
|
|
75
|
+
anchor.setAttribute('data-reader-external-href', href);
|
|
76
|
+
anchor.removeAttribute('href');
|
|
77
|
+
} else if (isExternalReference(href)) {
|
|
78
|
+
anchor.removeAttribute('href');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sanitizeEpubStylesheet(
|
|
84
|
+
source: string,
|
|
85
|
+
resourcePath: string,
|
|
86
|
+
resolveResourceUrl: (path: string) => string | undefined,
|
|
87
|
+
): string {
|
|
88
|
+
const withoutImports = source.replaceAll(/@import\s+(?:url\([^)]*\)|[^;]+);?/gi, '');
|
|
89
|
+
return withoutImports.replaceAll(
|
|
90
|
+
/url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/gi,
|
|
91
|
+
(match, _quote, value: string) => {
|
|
92
|
+
if (isExternalReference(value)) return 'url(about:blank)';
|
|
93
|
+
try {
|
|
94
|
+
return `url("${resolveResourceUrl(resolveArchivePath(resourcePath, value)) ?? 'about:blank'}")`;
|
|
95
|
+
} catch {
|
|
96
|
+
return 'url(about:blank)';
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function rewriteResourceAttribute(
|
|
103
|
+
element: Element,
|
|
104
|
+
attributeName: string,
|
|
105
|
+
resourcePath: string,
|
|
106
|
+
resolveResourceUrl: (path: string) => string | undefined,
|
|
107
|
+
): void {
|
|
108
|
+
const value = element.getAttribute(attributeName)?.trim();
|
|
109
|
+
if (!value) return;
|
|
110
|
+
if (isExternalReference(value)) {
|
|
111
|
+
element.removeAttribute(attributeName);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const resolved = resolveResourceUrl(resolveArchivePath(resourcePath, value));
|
|
116
|
+
if (resolved === undefined) element.removeAttribute(attributeName);
|
|
117
|
+
else element.setAttribute(attributeName, resolved);
|
|
118
|
+
} catch {
|
|
119
|
+
element.removeAttribute(attributeName);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { loadReaderSourceAsync } from './loadReaderSourceAsync';
|
|
4
|
+
|
|
5
|
+
const originalFetch = globalThis.fetch;
|
|
6
|
+
|
|
7
|
+
describe('loadReaderSourceAsync', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.fetch = originalFetch;
|
|
10
|
+
mock.restore();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('loads a bounded source without forwarding credentials', async () => {
|
|
14
|
+
const requests: RequestInit[] = [];
|
|
15
|
+
globalThis.fetch = mock((_url: string | URL | Request, init?: RequestInit) => {
|
|
16
|
+
requests.push(init ?? {});
|
|
17
|
+
return Promise.resolve(new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
|
|
18
|
+
}) as typeof fetch;
|
|
19
|
+
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
expect(await loadReaderSourceAsync('https://example.test/book.pdf', controller.signal)).toEqual(
|
|
22
|
+
new Uint8Array([1, 2, 3]),
|
|
23
|
+
);
|
|
24
|
+
expect(requests[0]?.credentials).toBe('omit');
|
|
25
|
+
expect(requests[0]?.signal).toBe(controller.signal);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('rejects unsuccessful responses before reading their body', () => {
|
|
29
|
+
globalThis.fetch = mock(() =>
|
|
30
|
+
Promise.resolve(new Response('missing', { status: 404 })),
|
|
31
|
+
) as typeof fetch;
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
loadReaderSourceAsync('https://example.test/missing.epub', new AbortController().signal),
|
|
35
|
+
).rejects.toThrow('HTTP 404');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects a declared body larger than the reader download budget', () => {
|
|
39
|
+
globalThis.fetch = mock(() =>
|
|
40
|
+
Promise.resolve(
|
|
41
|
+
new Response(null, {
|
|
42
|
+
headers: { 'content-length': String(256 * 1024 * 1024 + 1) },
|
|
43
|
+
status: 200,
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
) as typeof fetch;
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
loadReaderSourceAsync('https://example.test/huge.epub', new AbortController().signal),
|
|
50
|
+
).rejects.toThrow('download limit');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const MAX_READER_SOURCE_BYTES = 256 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export async function loadReaderSourceAsync(
|
|
4
|
+
sourceUri: string,
|
|
5
|
+
signal: AbortSignal,
|
|
6
|
+
): Promise<Uint8Array> {
|
|
7
|
+
const response = await fetch(sourceUri, {
|
|
8
|
+
credentials: 'omit',
|
|
9
|
+
signal,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
throw new Error(`Reader source request failed with HTTP ${response.status}.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
17
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_READER_SOURCE_BYTES) {
|
|
18
|
+
throw new Error(`Reader source exceeds the ${MAX_READER_SOURCE_BYTES}-byte download limit.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (response.body === null) {
|
|
22
|
+
return validateSourceSize(new Uint8Array(await response.arrayBuffer()));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const reader = response.body.getReader();
|
|
26
|
+
const chunks: Uint8Array[] = [];
|
|
27
|
+
let byteLength = 0;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for (;;) {
|
|
31
|
+
const result = await reader.read();
|
|
32
|
+
if (result.done) break;
|
|
33
|
+
byteLength += result.value.byteLength;
|
|
34
|
+
if (byteLength > MAX_READER_SOURCE_BYTES) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Reader source exceeds the ${MAX_READER_SOURCE_BYTES}-byte download limit.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
chunks.push(result.value);
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const bytes = new Uint8Array(byteLength);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const chunk of chunks) {
|
|
48
|
+
bytes.set(chunk, offset);
|
|
49
|
+
offset += chunk.byteLength;
|
|
50
|
+
}
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateSourceSize(bytes: Uint8Array): Uint8Array {
|
|
55
|
+
if (bytes.byteLength > MAX_READER_SOURCE_BYTES) {
|
|
56
|
+
throw new Error(`Reader source exceeds the ${MAX_READER_SOURCE_BYTES}-byte download limit.`);
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReaderViewportState } from './types';
|
|
2
|
+
|
|
3
|
+
export const LOADING_STATE: ReaderViewportState = {
|
|
4
|
+
canGoNext: false,
|
|
5
|
+
canGoPrevious: false,
|
|
6
|
+
page: 1,
|
|
7
|
+
progress: 0,
|
|
8
|
+
status: 'loading',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const READER_STYLES = `
|
|
12
|
+
:root, body, #root { height: 100%; margin: 0; overflow: hidden; }
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
.reader-root { background: #fff; color: #111; height: 100%; outline: none; overflow: hidden; touch-action: pan-y; width: 100%; }
|
|
15
|
+
.reader-dark { background: #111; color: #f5f5f5; }
|
|
16
|
+
.reader-sepia { background: #f4ecd8; color: #3f3527; }
|
|
17
|
+
.reader-content { height: 100%; overflow: auto; position: relative; width: 100%; }
|
|
18
|
+
.reader-content > div:not(.reader-pdf-page) { height: 100%; width: 100%; }
|
|
19
|
+
.reader-pdf-page { --user-unit: 1; --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); margin: 0 auto; position: relative; }
|
|
20
|
+
.reader-pdf-page canvas { display: block; position: absolute; inset: 0; }
|
|
21
|
+
.reader-pdf-text-layer { --min-font-size: 1; --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); --min-font-size-inv: calc(1 / var(--min-font-size)); inset: 0; line-height: 1; opacity: 1; overflow: hidden; position: absolute; text-align: initial; text-size-adjust: none; transform-origin: 0 0; }
|
|
22
|
+
.reader-pdf-text-layer span, .reader-pdf-text-layer br { color: transparent; cursor: text; position: absolute; transform-origin: 0 0; user-select: text; white-space: pre; }
|
|
23
|
+
.reader-pdf-text-layer > :not(.markedContent), .reader-pdf-text-layer .markedContent span:not(.markedContent) { --font-height: 0; --scale-x: 1; --rotate: 0deg; font-size: calc(var(--text-scale-factor) * var(--font-height)); transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); }
|
|
24
|
+
.reader-pdf-text-layer .markedContent { display: contents; }
|
|
25
|
+
.reader-pdf-text-layer ::selection { background: rgba(0, 100, 255, 0.35); }
|
|
26
|
+
.reader-pdf-link-layer { inset: 0; pointer-events: none; position: absolute; }
|
|
27
|
+
.reader-pdf-link-layer a { outline-offset: 2px; pointer-events: auto; position: absolute; }
|
|
28
|
+
`;
|
|
@@ -2,12 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
|
|
3
3
|
describe('reader renderer package boundary', () => {
|
|
4
4
|
test('provides the EPUB navigator and archive APIs required by the driver', async () => {
|
|
5
|
-
const [{ EpubNavigator }, { BlobReader, ZipReader }] = await Promise.all([
|
|
5
|
+
const [{ EpubNavigator }, { Publication }, { BlobReader, ZipReader }] = await Promise.all([
|
|
6
6
|
import('@readium/navigator'),
|
|
7
|
+
import('@readium/shared'),
|
|
7
8
|
import('@zip.js/zip.js'),
|
|
8
9
|
]);
|
|
9
10
|
|
|
10
11
|
expect(EpubNavigator).toBeFunction();
|
|
12
|
+
expect(Publication).toBeFunction();
|
|
11
13
|
expect(BlobReader).toBeFunction();
|
|
12
14
|
expect(ZipReader).toBeFunction();
|
|
13
15
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReaderErrorEvent, ReaderLocationChangeEvent } from '@ankhorage/zora';
|
|
2
|
+
import type { DOMProps } from 'expo/dom';
|
|
3
|
+
|
|
4
|
+
export interface ReaderAppearance {
|
|
5
|
+
readonly colorScheme: 'dark' | 'light' | 'sepia' | 'system';
|
|
6
|
+
readonly fontScale: number;
|
|
7
|
+
readonly lineHeight: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReaderCommand {
|
|
11
|
+
readonly id: number;
|
|
12
|
+
readonly sourceUri: string;
|
|
13
|
+
readonly type: 'next' | 'previous';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReaderViewportState {
|
|
17
|
+
readonly canGoNext: boolean;
|
|
18
|
+
readonly canGoPrevious: boolean;
|
|
19
|
+
readonly location?: ReaderLocationChangeEvent;
|
|
20
|
+
readonly page: number;
|
|
21
|
+
readonly pageCount?: number;
|
|
22
|
+
readonly progress: number;
|
|
23
|
+
readonly status: 'loading' | 'ready';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReaderViewportProps {
|
|
27
|
+
readonly appearance: ReaderAppearance;
|
|
28
|
+
readonly command?: ReaderCommand;
|
|
29
|
+
readonly format: 'epub' | 'pdf';
|
|
30
|
+
readonly initialLocation?: string;
|
|
31
|
+
readonly onError: (event: ReaderErrorEvent) => Promise<void>;
|
|
32
|
+
readonly onOpenExternalLink: (event: { readonly url: string }) => Promise<void>;
|
|
33
|
+
readonly onStateChange: (state: ReaderViewportState) => Promise<void>;
|
|
34
|
+
readonly sourceUri: string;
|
|
35
|
+
readonly dom?: DOMProps;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReaderDriverState {
|
|
39
|
+
readonly canGoNext: boolean;
|
|
40
|
+
readonly canGoPrevious: boolean;
|
|
41
|
+
readonly chapterId?: string;
|
|
42
|
+
readonly chapterTitle?: string;
|
|
43
|
+
readonly locator: string;
|
|
44
|
+
readonly page: number;
|
|
45
|
+
readonly pageCount?: number;
|
|
46
|
+
readonly progression: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ReaderDriver {
|
|
50
|
+
readonly direction: 'ltr' | 'rtl';
|
|
51
|
+
destroy(): Promise<void>;
|
|
52
|
+
getState(): ReaderDriverState;
|
|
53
|
+
goNext(trigger: ReaderLocationChangeEvent['trigger']): Promise<void>;
|
|
54
|
+
goPrevious(trigger: ReaderLocationChangeEvent['trigger']): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ReaderTouchStart {
|
|
58
|
+
readonly interactive: boolean;
|
|
59
|
+
readonly x: number;
|
|
60
|
+
readonly y: number;
|
|
61
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Entry } from '@zip.js/zip.js';
|
|
2
|
+
import {
|
|
3
|
+
ERR_OVERLAPPING_ENTRY,
|
|
4
|
+
TextReader,
|
|
5
|
+
Uint8ArrayReader,
|
|
6
|
+
Uint8ArrayWriter,
|
|
7
|
+
ZipReader,
|
|
8
|
+
ZipWriter,
|
|
9
|
+
} from '@zip.js/zip.js';
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import { validateEpubArchiveEntriesAsync } from './validateEpubArchiveEntriesAsync';
|
|
13
|
+
|
|
14
|
+
describe('validateEpubArchiveEntriesAsync archive budgets', () => {
|
|
15
|
+
test('accepts a bounded archive and preflights every file without extracting it', async () => {
|
|
16
|
+
const entries = await createArchiveEntries([
|
|
17
|
+
['mimetype', 'application/epub+zip'],
|
|
18
|
+
['META-INF/container.xml', '<container />'],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
expect(validateEpubArchiveEntriesAsync(entries)).resolves.toBeUndefined();
|
|
22
|
+
expect(entries.every((entry) => entry.directory || entry.localDirectory)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('rejects excessive entry counts before reading entry data', async () => {
|
|
26
|
+
const [entry] = await createArchiveEntries([['mimetype', 'application/epub+zip']]);
|
|
27
|
+
|
|
28
|
+
expect(validateEpubArchiveEntriesAsync(Array<Entry>(10_001).fill(entry))).rejects.toThrow(
|
|
29
|
+
'exceeds the 10000-entry limit',
|
|
30
|
+
);
|
|
31
|
+
expect(entry.localDirectory).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('rejects an oversized entry before reading entry data', async () => {
|
|
35
|
+
const [entry] = await createArchiveEntries([['chapter.xhtml', '<p>Chapter</p>']]);
|
|
36
|
+
entry.uncompressedSize = 64 * 1024 * 1024 + 1;
|
|
37
|
+
entry.compressedSize = entry.uncompressedSize;
|
|
38
|
+
|
|
39
|
+
expect(validateEpubArchiveEntriesAsync([entry])).rejects.toThrow(
|
|
40
|
+
'exceeds the 67108864-byte uncompressed limit',
|
|
41
|
+
);
|
|
42
|
+
expect(entry.localDirectory).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rejects excessive aggregate expansion before reading entry data', async () => {
|
|
46
|
+
const entries = await createArchiveEntries(
|
|
47
|
+
Array.from({ length: 9 }, (_, index) => [`chapter-${index}.xhtml`, '<p>Chapter</p>']),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
entry.uncompressedSize = 64 * 1024 * 1024;
|
|
52
|
+
entry.compressedSize = entry.uncompressedSize;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(validateEpubArchiveEntriesAsync(entries)).rejects.toThrow(
|
|
56
|
+
'exceeds the 536870912-byte aggregate uncompressed limit',
|
|
57
|
+
);
|
|
58
|
+
expect(entries.every((entry) => !entry.localDirectory)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('rejects suspicious compression ratios before reading entry data', async () => {
|
|
62
|
+
const [entry] = await createArchiveEntries([['chapter.xhtml', '<p>Chapter</p>']]);
|
|
63
|
+
entry.uncompressedSize = 10_001;
|
|
64
|
+
entry.compressedSize = 100;
|
|
65
|
+
|
|
66
|
+
expect(validateEpubArchiveEntriesAsync([entry])).rejects.toThrow(
|
|
67
|
+
'exceeds the 100:1 compression-ratio limit',
|
|
68
|
+
);
|
|
69
|
+
expect(entry.localDirectory).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('validateEpubArchiveEntriesAsync archive structure', () => {
|
|
74
|
+
test('rejects invalid declared sizes before reading entry data', async () => {
|
|
75
|
+
const [entry] = await createArchiveEntries([['chapter.xhtml', '<p>Chapter</p>']]);
|
|
76
|
+
entry.compressedSize = Number.NaN;
|
|
77
|
+
|
|
78
|
+
expect(validateEpubArchiveEntriesAsync([entry])).rejects.toThrow('declares invalid sizes');
|
|
79
|
+
expect(entry.localDirectory).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('rejects overlapping entry ranges during the no-extraction preflight', async () => {
|
|
83
|
+
const archive = await createArchive([
|
|
84
|
+
['first.txt', 'same'],
|
|
85
|
+
['other.txt', 'same'],
|
|
86
|
+
]);
|
|
87
|
+
const centralDirectoryOffsets = findSignatureOffsets(archive, 0x02014b50);
|
|
88
|
+
const archiveView = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
|
|
89
|
+
const firstLocalHeaderOffset = archiveView.getUint32(centralDirectoryOffsets[0] + 42, true);
|
|
90
|
+
const firstCentralFilename = archive.slice(
|
|
91
|
+
centralDirectoryOffsets[0] + 46,
|
|
92
|
+
centralDirectoryOffsets[0] + 46 + 'first.txt'.length,
|
|
93
|
+
);
|
|
94
|
+
archive.set(firstCentralFilename, centralDirectoryOffsets[1] + 46);
|
|
95
|
+
archiveView.setUint32(centralDirectoryOffsets[1] + 42, firstLocalHeaderOffset, true);
|
|
96
|
+
const entries = await readArchiveEntries(archive, 'balanced');
|
|
97
|
+
|
|
98
|
+
expect(validateEpubArchiveEntriesAsync(entries)).rejects.toThrow(ERR_OVERLAPPING_ENTRY);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function createArchive(
|
|
103
|
+
files: readonly (readonly [filename: string, content: string])[],
|
|
104
|
+
): Promise<Uint8Array> {
|
|
105
|
+
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
106
|
+
|
|
107
|
+
for (const [filename, content] of files) {
|
|
108
|
+
await writer.add(filename, new TextReader(content));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return writer.close();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function createArchiveEntries(
|
|
115
|
+
files: readonly (readonly [filename: string, content: string])[],
|
|
116
|
+
): Promise<Entry[]> {
|
|
117
|
+
return readArchiveEntries(await createArchive(files));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findSignatureOffsets(data: Uint8Array, signature: number): number[] {
|
|
121
|
+
const offsets: number[] = [];
|
|
122
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
123
|
+
|
|
124
|
+
for (let offset = 0; offset <= data.byteLength - 4; offset += 1) {
|
|
125
|
+
if (view.getUint32(offset, true) === signature) {
|
|
126
|
+
offsets.push(offset);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return offsets;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readArchiveEntries(
|
|
134
|
+
data: Uint8Array,
|
|
135
|
+
strictness: 'balanced' | 'strict' = 'strict',
|
|
136
|
+
): Promise<Entry[]> {
|
|
137
|
+
const reader = new ZipReader(new Uint8ArrayReader(data), {
|
|
138
|
+
strictness,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return reader.getEntries({ strictness });
|
|
142
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Entry } from '@zip.js/zip.js';
|
|
2
|
+
import { Uint8ArrayWriter } from '@zip.js/zip.js';
|
|
3
|
+
|
|
4
|
+
const MAX_COMPRESSION_RATIO = 100;
|
|
5
|
+
const MAX_ENTRY_COUNT = 10_000;
|
|
6
|
+
const MAX_ENTRY_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export async function validateEpubArchiveEntriesAsync(
|
|
10
|
+
entries: readonly Entry[],
|
|
11
|
+
signal?: AbortSignal,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
if (entries.length > MAX_ENTRY_COUNT) {
|
|
14
|
+
throw new Error(`EPUB archive exceeds the ${MAX_ENTRY_COUNT}-entry limit.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let totalUncompressedBytes = 0;
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
validateDeclaredSizes(entry);
|
|
21
|
+
|
|
22
|
+
if (entry.directory) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (entry.uncompressedSize > MAX_ENTRY_UNCOMPRESSED_BYTES) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`EPUB archive entry "${entry.filename}" exceeds the ${MAX_ENTRY_UNCOMPRESSED_BYTES}-byte uncompressed limit.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
entry.uncompressedSize > 0 &&
|
|
34
|
+
(entry.compressedSize === 0 ||
|
|
35
|
+
entry.uncompressedSize > entry.compressedSize * MAX_COMPRESSION_RATIO)
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`EPUB archive entry "${entry.filename}" exceeds the ${MAX_COMPRESSION_RATIO}:1 compression-ratio limit.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (totalUncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES - entry.uncompressedSize) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`EPUB archive exceeds the ${MAX_TOTAL_UNCOMPRESSED_BYTES}-byte aggregate uncompressed limit.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
totalUncompressedBytes += entry.uncompressedSize;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (entry.directory) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await entry.getData(new Uint8ArrayWriter(), {
|
|
57
|
+
checkOverlappingEntryOnly: true,
|
|
58
|
+
signal,
|
|
59
|
+
strictness: 'strict',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateDeclaredSizes(entry: Entry): void {
|
|
65
|
+
if (
|
|
66
|
+
!Number.isSafeInteger(entry.compressedSize) ||
|
|
67
|
+
entry.compressedSize < 0 ||
|
|
68
|
+
!Number.isSafeInteger(entry.uncompressedSize) ||
|
|
69
|
+
entry.uncompressedSize < 0
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(`EPUB archive entry "${entry.filename}" declares invalid sizes.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -97,6 +97,42 @@ describe('resolveExpoRuntimePlan barcode capability', () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
describe('resolveExpoRuntimePlan ebook reader capability', () => {
|
|
101
|
+
test('selects the reader adapter and exact renderer packages without permissions', () => {
|
|
102
|
+
const plan = resolveExpoRuntimePlan(
|
|
103
|
+
withFirstScreenRequirements({ capabilities: [{ capability: 'ebookReader' }] }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(plan.permissions).toEqual([]);
|
|
107
|
+
expect(plan.impliedPermissions).toEqual([]);
|
|
108
|
+
expect(plan.providers).toEqual([]);
|
|
109
|
+
expect(plan.nativeConfig.plugins).toEqual([]);
|
|
110
|
+
expect(plan.runtimeAdapters).toEqual(['ExpoReaderSurfaceAdapter']);
|
|
111
|
+
expect(plan.dependencies.map(({ name, version }) => ({ name, version }))).toEqual([
|
|
112
|
+
{
|
|
113
|
+
name: '@ankhorage/expo-runtime',
|
|
114
|
+
version: GENERATED_RUNTIME_DEPENDENCY_VERSIONS['@ankhorage/expo-runtime'],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: '@readium/navigator',
|
|
118
|
+
version: GENERATED_RUNTIME_DEPENDENCY_VERSIONS['@readium/navigator'],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: '@readium/shared',
|
|
122
|
+
version: GENERATED_RUNTIME_DEPENDENCY_VERSIONS['@readium/shared'],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: '@zip.js/zip.js',
|
|
126
|
+
version: GENERATED_RUNTIME_DEPENDENCY_VERSIONS['@zip.js/zip.js'],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'pdfjs-dist',
|
|
130
|
+
version: GENERATED_RUNTIME_DEPENDENCY_VERSIONS['pdfjs-dist'],
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
100
136
|
describe('resolveExpoRuntimePlan Expo 57 plugin coverage', () => {
|
|
101
137
|
test('keeps microphone-only native configuration free of background audio behavior', () => {
|
|
102
138
|
const plan = resolveExpoRuntimePlan(
|