@ankhorage/expo-runtime 3.1.1 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -1
  3. package/dist/barcodeScanner.d.ts +2 -0
  4. package/dist/barcodeScanner.d.ts.map +1 -0
  5. package/dist/barcodeScanner.js +2 -0
  6. package/dist/barcodeScanner.js.map +1 -0
  7. package/dist/createExpoRuntimeRegistry.d.ts.map +1 -1
  8. package/dist/createExpoRuntimeRegistry.js +5 -0
  9. package/dist/createExpoRuntimeRegistry.js.map +1 -1
  10. package/dist/expoRuntimePlanningMetadata.d.ts +9 -1
  11. package/dist/expoRuntimePlanningMetadata.d.ts.map +1 -1
  12. package/dist/expoRuntimePlanningMetadata.js +8 -0
  13. package/dist/expoRuntimePlanningMetadata.js.map +1 -1
  14. package/dist/generatedSources.d.ts +1 -0
  15. package/dist/generatedSources.d.ts.map +1 -1
  16. package/dist/generatedSources.js +9 -1
  17. package/dist/generatedSources.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/planning.d.ts +1 -1
  23. package/dist/planning.d.ts.map +1 -1
  24. package/dist/planning.js +1 -1
  25. package/dist/planning.js.map +1 -1
  26. package/dist/reader/ExpoReaderSurfaceAdapter.d.ts +4 -0
  27. package/dist/reader/ExpoReaderSurfaceAdapter.d.ts.map +1 -0
  28. package/dist/reader/ExpoReaderSurfaceAdapter.js +102 -0
  29. package/dist/reader/ExpoReaderSurfaceAdapter.js.map +1 -0
  30. package/dist/reader/ReaderDocumentError.d.ts +6 -0
  31. package/dist/reader/ReaderDocumentError.d.ts.map +1 -0
  32. package/dist/reader/ReaderDocumentError.js +9 -0
  33. package/dist/reader/ReaderDocumentError.js.map +1 -0
  34. package/dist/reader/ReaderViewport.d.ts +4 -0
  35. package/dist/reader/ReaderViewport.d.ts.map +1 -0
  36. package/dist/reader/ReaderViewport.js +212 -0
  37. package/dist/reader/ReaderViewport.js.map +1 -0
  38. package/dist/reader/drivers/EpubReaderDriver.d.ts +20 -0
  39. package/dist/reader/drivers/EpubReaderDriver.d.ts.map +1 -0
  40. package/dist/reader/drivers/EpubReaderDriver.js +259 -0
  41. package/dist/reader/drivers/EpubReaderDriver.js.map +1 -0
  42. package/dist/reader/drivers/PdfReaderDriver.d.ts +18 -0
  43. package/dist/reader/drivers/PdfReaderDriver.d.ts.map +1 -0
  44. package/dist/reader/drivers/PdfReaderDriver.js +198 -0
  45. package/dist/reader/drivers/PdfReaderDriver.js.map +1 -0
  46. package/dist/reader/epub/EpubArchive.d.ts +11 -0
  47. package/dist/reader/epub/EpubArchive.d.ts.map +1 -0
  48. package/dist/reader/epub/EpubArchive.js +65 -0
  49. package/dist/reader/epub/EpubArchive.js.map +1 -0
  50. package/dist/reader/epub/EpubArchiveFetcher.d.ts +11 -0
  51. package/dist/reader/epub/EpubArchiveFetcher.d.ts.map +1 -0
  52. package/dist/reader/epub/EpubArchiveFetcher.js +44 -0
  53. package/dist/reader/epub/EpubArchiveFetcher.js.map +1 -0
  54. package/dist/reader/epub/mediaTypes.d.ts +3 -0
  55. package/dist/reader/epub/mediaTypes.d.ts.map +1 -0
  56. package/dist/reader/epub/mediaTypes.js +26 -0
  57. package/dist/reader/epub/mediaTypes.js.map +1 -0
  58. package/dist/reader/epub/parseEpubPackage.d.ts +22 -0
  59. package/dist/reader/epub/parseEpubPackage.d.ts.map +1 -0
  60. package/dist/reader/epub/parseEpubPackage.js +98 -0
  61. package/dist/reader/epub/parseEpubPackage.js.map +1 -0
  62. package/dist/reader/epub/path.d.ts +4 -0
  63. package/dist/reader/epub/path.d.ts.map +1 -0
  64. package/dist/reader/epub/path.js +28 -0
  65. package/dist/reader/epub/path.js.map +1 -0
  66. package/dist/reader/epub/sanitizeEpubDocument.d.ts +3 -0
  67. package/dist/reader/epub/sanitizeEpubDocument.d.ts.map +1 -0
  68. package/dist/reader/epub/sanitizeEpubDocument.js +90 -0
  69. package/dist/reader/epub/sanitizeEpubDocument.js.map +1 -0
  70. package/dist/reader/index.d.ts +2 -0
  71. package/dist/reader/index.d.ts.map +1 -0
  72. package/dist/reader/index.js +2 -0
  73. package/dist/reader/index.js.map +1 -0
  74. package/dist/reader/loadReaderSourceAsync.d.ts +2 -0
  75. package/dist/reader/loadReaderSourceAsync.d.ts.map +1 -0
  76. package/dist/reader/loadReaderSourceAsync.js +49 -0
  77. package/dist/reader/loadReaderSourceAsync.js.map +1 -0
  78. package/dist/reader/readerViewportPresentation.d.ts +4 -0
  79. package/dist/reader/readerViewportPresentation.d.ts.map +1 -0
  80. package/dist/reader/readerViewportPresentation.js +26 -0
  81. package/dist/reader/readerViewportPresentation.js.map +1 -0
  82. package/dist/reader/types.d.ts +57 -0
  83. package/dist/reader/types.d.ts.map +1 -0
  84. package/dist/reader/types.js +2 -0
  85. package/dist/reader/types.js.map +1 -0
  86. package/dist/reader/validateEpubArchiveEntriesAsync.d.ts +3 -0
  87. package/dist/reader/validateEpubArchiveEntriesAsync.d.ts.map +1 -0
  88. package/dist/reader/validateEpubArchiveEntriesAsync.js +48 -0
  89. package/dist/reader/validateEpubArchiveEntriesAsync.js.map +1 -0
  90. package/package.json +25 -4
  91. package/src/adapterEntrypoints.test.ts +48 -0
  92. package/src/barcodeScanner.ts +1 -0
  93. package/src/createExpoRuntimeRegistry.test.tsx +9 -1
  94. package/src/createExpoRuntimeRegistry.tsx +7 -1
  95. package/src/expoRuntimePlanningMetadata.ts +9 -1
  96. package/src/generatedSources.test.ts +11 -2
  97. package/src/generatedSources.ts +11 -1
  98. package/src/index.ts +5 -1
  99. package/src/planning.ts +4 -1
  100. package/src/reader/ExpoReaderSurfaceAdapter.tsx +170 -0
  101. package/src/reader/ReaderDocumentError.ts +11 -0
  102. package/src/reader/ReaderViewport.tsx +319 -0
  103. package/src/reader/drivers/EpubReaderDriver.ts +309 -0
  104. package/src/reader/drivers/PdfReaderDriver.ts +253 -0
  105. package/src/reader/epub/EpubArchive.ts +83 -0
  106. package/src/reader/epub/EpubArchiveFetcher.ts +58 -0
  107. package/src/reader/epub/mediaTypes.ts +16 -0
  108. package/src/reader/epub/parseEpubPackage.test.ts +116 -0
  109. package/src/reader/epub/parseEpubPackage.ts +152 -0
  110. package/src/reader/epub/path.test.ts +22 -0
  111. package/src/reader/epub/path.ts +26 -0
  112. package/src/reader/epub/sanitizeEpubDocument.ts +121 -0
  113. package/src/reader/index.ts +1 -0
  114. package/src/reader/loadReaderSourceAsync.test.ts +52 -0
  115. package/src/reader/loadReaderSourceAsync.ts +59 -0
  116. package/src/reader/readerViewportPresentation.ts +28 -0
  117. package/src/reader/rendererPackages.test.ts +3 -1
  118. package/src/reader/types.ts +61 -0
  119. package/src/reader/validateEpubArchiveEntriesAsync.test.ts +142 -0
  120. package/src/reader/validateEpubArchiveEntriesAsync.ts +73 -0
  121. package/src/resolveExpoRuntimePlan.test.ts +36 -0
@@ -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
+ }
@@ -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 @@
1
+ export { ExpoReaderSurfaceAdapter } from './ExpoReaderSurfaceAdapter';
@@ -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
+ }