@ankhorage/expo-runtime 3.1.0 → 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 +19 -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/navigation/resolveReaderKeyboardIntent.d.ts +9 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.d.ts.map +1 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.js +19 -0
- package/dist/reader/navigation/resolveReaderKeyboardIntent.js.map +1 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.d.ts +11 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.d.ts.map +1 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.js +16 -0
- package/dist/reader/navigation/resolveReaderNavigationIntent.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 +27 -5
- 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/navigation/resolveReaderKeyboardIntent.test.ts +38 -0
- package/src/reader/navigation/resolveReaderKeyboardIntent.ts +20 -0
- package/src/reader/navigation/resolveReaderNavigationIntent.test.ts +48 -0
- package/src/reader/navigation/resolveReaderNavigationIntent.ts +23 -0
- package/src/reader/readerViewportPresentation.ts +28 -0
- package/src/reader/rendererPackages.test.ts +24 -0
- 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,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveReaderKeyboardIntent } from './resolveReaderKeyboardIntent';
|
|
4
|
+
|
|
5
|
+
const defaultInput = {
|
|
6
|
+
canGoNext: true,
|
|
7
|
+
canGoPrevious: true,
|
|
8
|
+
direction: 'ltr' as const,
|
|
9
|
+
key: 'ArrowRight',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('resolveReaderKeyboardIntent', () => {
|
|
13
|
+
test('maps directional keys in LTR and RTL publications', () => {
|
|
14
|
+
expect(resolveReaderKeyboardIntent(defaultInput)).toBe('next');
|
|
15
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, key: 'ArrowLeft' })).toBe('previous');
|
|
16
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, direction: 'rtl' })).toBe('previous');
|
|
17
|
+
expect(
|
|
18
|
+
resolveReaderKeyboardIntent({ ...defaultInput, direction: 'rtl', key: 'ArrowLeft' }),
|
|
19
|
+
).toBe('next');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('keeps PageUp and PageDown tied to logical page order', () => {
|
|
23
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, key: 'PageUp' })).toBe('previous');
|
|
24
|
+
expect(
|
|
25
|
+
resolveReaderKeyboardIntent({ ...defaultInput, direction: 'rtl', key: 'PageDown' }),
|
|
26
|
+
).toBe('next');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('ignores modified, interactive, unsupported, and boundary input', () => {
|
|
30
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, hasModifier: true })).toBeNull();
|
|
31
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, isInteractiveTarget: true })).toBeNull();
|
|
32
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, key: 'Enter' })).toBeNull();
|
|
33
|
+
expect(resolveReaderKeyboardIntent({ ...defaultInput, canGoNext: false })).toBeNull();
|
|
34
|
+
expect(
|
|
35
|
+
resolveReaderKeyboardIntent({ ...defaultInput, canGoPrevious: false, key: 'ArrowLeft' }),
|
|
36
|
+
).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function resolveReaderKeyboardIntent(input: {
|
|
2
|
+
canGoNext: boolean;
|
|
3
|
+
canGoPrevious: boolean;
|
|
4
|
+
direction: 'ltr' | 'rtl';
|
|
5
|
+
hasModifier?: boolean;
|
|
6
|
+
isInteractiveTarget?: boolean;
|
|
7
|
+
key: string;
|
|
8
|
+
}): 'next' | 'previous' | null {
|
|
9
|
+
if (input.hasModifier || input.isInteractiveTarget) return null;
|
|
10
|
+
|
|
11
|
+
let intent: 'next' | 'previous' | null = null;
|
|
12
|
+
if (input.key === 'PageUp') intent = 'previous';
|
|
13
|
+
if (input.key === 'PageDown') intent = 'next';
|
|
14
|
+
if (input.key === 'ArrowLeft') intent = input.direction === 'ltr' ? 'previous' : 'next';
|
|
15
|
+
if (input.key === 'ArrowRight') intent = input.direction === 'ltr' ? 'next' : 'previous';
|
|
16
|
+
|
|
17
|
+
if (intent === 'next') return input.canGoNext ? intent : null;
|
|
18
|
+
if (intent === 'previous') return input.canGoPrevious ? intent : null;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveReaderNavigationIntent } from './resolveReaderNavigationIntent';
|
|
4
|
+
|
|
5
|
+
const defaultInput = {
|
|
6
|
+
canGoNext: true,
|
|
7
|
+
canGoPrevious: true,
|
|
8
|
+
deltaX: -49,
|
|
9
|
+
deltaY: 0,
|
|
10
|
+
direction: 'ltr' as const,
|
|
11
|
+
viewportWidth: 300,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('resolveReaderNavigationIntent', () => {
|
|
15
|
+
test('maps one qualified LTR swipe to its logical page direction', () => {
|
|
16
|
+
expect(resolveReaderNavigationIntent(defaultInput)).toBe('next');
|
|
17
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, deltaX: 49 })).toBe('previous');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('inverts horizontal navigation for RTL publications', () => {
|
|
21
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, direction: 'rtl' })).toBe('previous');
|
|
22
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, deltaX: 49, direction: 'rtl' })).toBe(
|
|
23
|
+
'next',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('requires horizontal intent and a strict responsive distance threshold', () => {
|
|
28
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, deltaX: -100, deltaY: 80 })).toBeNull();
|
|
29
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, deltaX: -48 })).toBeNull();
|
|
30
|
+
expect(
|
|
31
|
+
resolveReaderNavigationIntent({ ...defaultInput, deltaX: -60, viewportWidth: 400 }),
|
|
32
|
+
).toBeNull();
|
|
33
|
+
expect(
|
|
34
|
+
resolveReaderNavigationIntent({ ...defaultInput, deltaX: -61, viewportWidth: 400 }),
|
|
35
|
+
).toBe('next');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('does not claim selection, interactive content, or a document boundary', () => {
|
|
39
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, hasSelection: true })).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
resolveReaderNavigationIntent({ ...defaultInput, isInteractiveTarget: true }),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
expect(resolveReaderNavigationIntent({ ...defaultInput, canGoNext: false })).toBeNull();
|
|
44
|
+
expect(
|
|
45
|
+
resolveReaderNavigationIntent({ ...defaultInput, canGoPrevious: false, deltaX: 49 }),
|
|
46
|
+
).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function resolveReaderNavigationIntent(input: {
|
|
2
|
+
canGoNext: boolean;
|
|
3
|
+
canGoPrevious: boolean;
|
|
4
|
+
deltaX: number;
|
|
5
|
+
deltaY: number;
|
|
6
|
+
direction: 'ltr' | 'rtl';
|
|
7
|
+
hasSelection?: boolean;
|
|
8
|
+
isInteractiveTarget?: boolean;
|
|
9
|
+
viewportWidth: number;
|
|
10
|
+
}): 'next' | 'previous' | null {
|
|
11
|
+
if (input.hasSelection || input.isInteractiveTarget) return null;
|
|
12
|
+
|
|
13
|
+
const horizontalDistance = Math.abs(input.deltaX);
|
|
14
|
+
const verticalDistance = Math.abs(input.deltaY);
|
|
15
|
+
const distanceThreshold = Math.max(48, input.viewportWidth * 0.15);
|
|
16
|
+
|
|
17
|
+
if (horizontalDistance <= verticalDistance * 1.25) return null;
|
|
18
|
+
if (horizontalDistance <= distanceThreshold) return null;
|
|
19
|
+
|
|
20
|
+
const movesForward = input.direction === 'ltr' ? input.deltaX < 0 : input.deltaX > 0;
|
|
21
|
+
if (movesForward) return input.canGoNext ? 'next' : null;
|
|
22
|
+
return input.canGoPrevious ? 'previous' : null;
|
|
23
|
+
}
|
|
@@ -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
|
+
`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
describe('reader renderer package boundary', () => {
|
|
4
|
+
test('provides the EPUB navigator and archive APIs required by the driver', async () => {
|
|
5
|
+
const [{ EpubNavigator }, { Publication }, { BlobReader, ZipReader }] = await Promise.all([
|
|
6
|
+
import('@readium/navigator'),
|
|
7
|
+
import('@readium/shared'),
|
|
8
|
+
import('@zip.js/zip.js'),
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
expect(EpubNavigator).toBeFunction();
|
|
12
|
+
expect(Publication).toBeFunction();
|
|
13
|
+
expect(BlobReader).toBeFunction();
|
|
14
|
+
expect(ZipReader).toBeFunction();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('provides the PDF loading, worker, and protected-document APIs', async () => {
|
|
18
|
+
const { getDocument, PasswordException, PDFWorker } = await import('pdfjs-dist');
|
|
19
|
+
|
|
20
|
+
expect(getDocument).toBeFunction();
|
|
21
|
+
expect(PasswordException).toBeFunction();
|
|
22
|
+
expect(PDFWorker).toBeFunction();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -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
|
+
}
|