@bendyline/squisq-formats 1.4.2 → 2.0.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/README.md +48 -2
- package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
- package/dist/chunk-2CSZJEAL.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
- package/dist/chunk-2EFDWKC6.js.map +1 -0
- package/dist/chunk-2PRVLAU3.js +740 -0
- package/dist/chunk-2PRVLAU3.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
- package/dist/chunk-4HJ6AK2Q.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
- package/dist/chunk-CVNVD7VH.js.map +1 -0
- package/dist/chunk-CX3LH47J.js +99 -0
- package/dist/chunk-CX3LH47J.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/chunk-H7T5DXUU.js +479 -0
- package/dist/chunk-H7T5DXUU.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
- package/dist/chunk-HBSCRS5P.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-X4USWOQ2.js +131 -0
- package/dist/chunk-X4USWOQ2.js.map +1 -0
- package/dist/chunk-XTLQXR4U.js +195 -0
- package/dist/chunk-XTLQXR4U.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/chunk-ZZ7D4DN6.js +97 -0
- package/dist/chunk-ZZ7D4DN6.js.map +1 -0
- package/dist/container/index.d.ts +6 -2
- package/dist/container/index.js +15 -17
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-Djev9BLU.d.ts +69 -0
- package/dist/extract-LNOA7DBK.js +15 -0
- package/dist/extract-LNOA7DBK.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-Yivc3kZM.d.ts +136 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +30 -11
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-7OUUVAGU.js +12 -0
- package/dist/layouts-7OUUVAGU.js.map +1 -0
- package/dist/layouts-CZ0bdsNL.d.ts +150 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-Gof6OrJX.d.ts +140 -0
- package/dist/registry/index.d.ts +46 -8
- package/dist/registry/index.js +5 -2
- package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +167 -0
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +59 -2
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +20 -29
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +20 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +168 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +87 -28
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1168 -0
- package/src/registry/convert.ts +54 -23
- package/src/registry/defaultFormats.ts +131 -44
- package/src/registry/index.ts +3 -0
- package/src/registry/types.ts +52 -6
- package/src/shared/boundedZipArchive.ts +342 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +97 -0
- package/src/shared/zipLimits.ts +286 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import JSZip from 'jszip';
|
|
3
|
+
import { getPartXml, openPackage } from '../ooxml/reader';
|
|
4
|
+
import type { OoxmlPackage } from '../ooxml/types';
|
|
5
|
+
import { openBoundedZipArchive, validateZipArchive, ZipSafetyError } from '../shared/zipSafety';
|
|
6
|
+
import { declaredZipEntryCount } from '../shared/zipEntryCount';
|
|
7
|
+
|
|
8
|
+
interface InstrumentedStream {
|
|
9
|
+
pause(): InstrumentedStream;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StreamableEntry {
|
|
13
|
+
internalStream(type: 'uint8array'): InstrumentedStream;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function makeZip(
|
|
17
|
+
files: Record<string, string | Uint8Array>,
|
|
18
|
+
compression: 'STORE' | 'DEFLATE' = 'DEFLATE',
|
|
19
|
+
): Promise<Uint8Array> {
|
|
20
|
+
const zip = new JSZip();
|
|
21
|
+
for (const [path, data] of Object.entries(files)) zip.file(path, data);
|
|
22
|
+
return zip.generateAsync({ type: 'uint8array', compression });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function patchMemberMetadata(
|
|
26
|
+
source: Uint8Array,
|
|
27
|
+
path: string,
|
|
28
|
+
patch: { uncompressedSize?: number; crc32?: number },
|
|
29
|
+
): Uint8Array {
|
|
30
|
+
const bytes = source.slice();
|
|
31
|
+
const view = new DataView(bytes.buffer);
|
|
32
|
+
let patchedCentral = false;
|
|
33
|
+
|
|
34
|
+
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
35
|
+
const signature = view.getUint32(offset, true);
|
|
36
|
+
let nameOffset: number;
|
|
37
|
+
let nameLengthOffset: number;
|
|
38
|
+
let uncompressedSizeOffset: number;
|
|
39
|
+
let crcOffset: number;
|
|
40
|
+
if (signature === 0x02014b50) {
|
|
41
|
+
nameOffset = offset + 46;
|
|
42
|
+
nameLengthOffset = offset + 28;
|
|
43
|
+
uncompressedSizeOffset = offset + 24;
|
|
44
|
+
crcOffset = offset + 16;
|
|
45
|
+
} else if (signature === 0x04034b50) {
|
|
46
|
+
nameOffset = offset + 30;
|
|
47
|
+
nameLengthOffset = offset + 26;
|
|
48
|
+
uncompressedSizeOffset = offset + 22;
|
|
49
|
+
crcOffset = offset + 14;
|
|
50
|
+
} else {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
55
|
+
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
56
|
+
if (name !== path) continue;
|
|
57
|
+
if (patch.uncompressedSize !== undefined) {
|
|
58
|
+
view.setUint32(uncompressedSizeOffset, patch.uncompressedSize, true);
|
|
59
|
+
}
|
|
60
|
+
if (patch.crc32 !== undefined) view.setUint32(crcOffset, patch.crc32, true);
|
|
61
|
+
if (signature === 0x02014b50) patchedCentral = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expect(patchedCentral).toBe(true);
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renameZipMember(source: Uint8Array, from: string, to: string): Uint8Array {
|
|
69
|
+
expect(to.length).toBe(from.length);
|
|
70
|
+
const bytes = source.slice();
|
|
71
|
+
const view = new DataView(bytes.buffer);
|
|
72
|
+
const replacement = new TextEncoder().encode(to);
|
|
73
|
+
let renamedCentral = false;
|
|
74
|
+
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
75
|
+
const signature = view.getUint32(offset, true);
|
|
76
|
+
const isCentral = signature === 0x02014b50;
|
|
77
|
+
const isLocal = signature === 0x04034b50;
|
|
78
|
+
if (!isCentral && !isLocal) continue;
|
|
79
|
+
const nameLengthOffset = offset + (isCentral ? 28 : 26);
|
|
80
|
+
const nameOffset = offset + (isCentral ? 46 : 30);
|
|
81
|
+
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
82
|
+
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
83
|
+
if (name !== from) continue;
|
|
84
|
+
bytes.set(replacement, nameOffset);
|
|
85
|
+
if (isCentral) renamedCentral = true;
|
|
86
|
+
}
|
|
87
|
+
expect(renamedCentral).toBe(true);
|
|
88
|
+
return bytes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('bounded JSZip reads', () => {
|
|
92
|
+
it('preflights duplicate central records before JSZip collapses their names', async () => {
|
|
93
|
+
const valid = await makeZip({ 'one.txt': '1', 'two.txt': '2' }, 'STORE');
|
|
94
|
+
const duplicate = renameZipMember(valid, 'two.txt', 'one.txt');
|
|
95
|
+
const parsed = await JSZip.loadAsync(duplicate);
|
|
96
|
+
expect(Object.values(parsed.files).filter((entry) => !entry.dir)).toHaveLength(1);
|
|
97
|
+
|
|
98
|
+
const blob = new Blob([duplicate.slice().buffer]);
|
|
99
|
+
await expect(openBoundedZipArchive(blob, { maxEntries: 1 })).rejects.toMatchObject({
|
|
100
|
+
code: 'too-many-entries',
|
|
101
|
+
limit: 1,
|
|
102
|
+
actual: 2,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('counts directory-only central records toward the entry limit', async () => {
|
|
107
|
+
const zip = new JSZip();
|
|
108
|
+
zip.folder('one');
|
|
109
|
+
zip.folder('two');
|
|
110
|
+
const data = await zip.generateAsync({ type: 'uint8array' });
|
|
111
|
+
await expect(openBoundedZipArchive(data, { maxEntries: 1 })).rejects.toMatchObject({
|
|
112
|
+
code: 'too-many-entries',
|
|
113
|
+
actual: 2,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('finds the EOCD record with a comment containing signature-like bytes', async () => {
|
|
118
|
+
const zip = new JSZip();
|
|
119
|
+
zip.file('one.txt', '1');
|
|
120
|
+
zip.file('two.txt', '2');
|
|
121
|
+
const data = await zip.generateAsync({
|
|
122
|
+
type: 'uint8array',
|
|
123
|
+
comment: 'looks like PK\u0005\u0006 but is only a comment',
|
|
124
|
+
});
|
|
125
|
+
expect(declaredZipEntryCount(data)).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reads ZIP64 record counts and falls back when ZIP64 metadata is ambiguous', () => {
|
|
129
|
+
const zip64 = new Uint8Array(98);
|
|
130
|
+
const view = new DataView(zip64.buffer);
|
|
131
|
+
view.setUint32(0, 0x06064b50, true);
|
|
132
|
+
view.setUint32(32, 3, true);
|
|
133
|
+
view.setUint32(56, 0x07064b50, true);
|
|
134
|
+
view.setUint32(64, 0, true);
|
|
135
|
+
view.setUint32(76, 0x06054b50, true);
|
|
136
|
+
view.setUint16(86, 0xffff, true);
|
|
137
|
+
expect(declaredZipEntryCount(zip64)).toBe(3);
|
|
138
|
+
|
|
139
|
+
zip64.fill(0, 56, 76);
|
|
140
|
+
expect(declaredZipEntryCount(zip64)).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('maps Blob read failures to a structured invalid-archive error', async () => {
|
|
144
|
+
const unreadable = {
|
|
145
|
+
size: 10,
|
|
146
|
+
slice: () => unreadable,
|
|
147
|
+
arrayBuffer: () => Promise.reject(new Error('read failed')),
|
|
148
|
+
} as unknown as Blob;
|
|
149
|
+
await expect(openBoundedZipArchive(unreadable)).rejects.toMatchObject({
|
|
150
|
+
code: 'invalid-archive',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns structured errors while preserving established messages', async () => {
|
|
155
|
+
const data = await makeZip({ 'large.txt': '12345' }, 'STORE');
|
|
156
|
+
let error: unknown;
|
|
157
|
+
try {
|
|
158
|
+
await openBoundedZipArchive(data, { maxEntryUncompressedBytes: 4 });
|
|
159
|
+
} catch (caught: unknown) {
|
|
160
|
+
error = caught;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(error).toBeInstanceOf(ZipSafetyError);
|
|
164
|
+
expect(error).toMatchObject({
|
|
165
|
+
name: 'ZipSafetyError',
|
|
166
|
+
code: 'entry-too-large',
|
|
167
|
+
path: 'large.txt',
|
|
168
|
+
limit: 4,
|
|
169
|
+
actual: 5,
|
|
170
|
+
});
|
|
171
|
+
expect((error as Error).message).toMatch(/exceeds 4 byte per-entry limit/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a high-ratio member from central-directory metadata', async () => {
|
|
175
|
+
const data = await makeZip({ 'repeated.bin': new Uint8Array(128 * 1024) });
|
|
176
|
+
await expect(openBoundedZipArchive(data, { maxCompressionRatio: 2 })).rejects.toMatchObject({
|
|
177
|
+
code: 'compression-ratio-exceeded',
|
|
178
|
+
path: 'repeated.bin',
|
|
179
|
+
limit: 2,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('enforces the default compression-ratio ceiling against a single-member bomb', async () => {
|
|
184
|
+
const data = await makeZip({ 'zeros.bin': new Uint8Array(2 * 1024 * 1024) });
|
|
185
|
+
await expect(openBoundedZipArchive(data)).rejects.toMatchObject({
|
|
186
|
+
code: 'compression-ratio-exceeded',
|
|
187
|
+
path: 'zeros.bin',
|
|
188
|
+
limit: 1000,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('halts an underdeclared member on its first emitted chunk without retaining it', async () => {
|
|
193
|
+
const valid = await makeZip({ 'bomb.txt': new Uint8Array(1024 * 1024) });
|
|
194
|
+
const forged = patchMemberMetadata(valid, 'bomb.txt', { uncompressedSize: 1 });
|
|
195
|
+
const archive = await openBoundedZipArchive(forged);
|
|
196
|
+
|
|
197
|
+
await expect(archive.read('bomb.txt')).rejects.toMatchObject({
|
|
198
|
+
code: 'size-mismatch',
|
|
199
|
+
path: 'bomb.txt',
|
|
200
|
+
});
|
|
201
|
+
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('pauses every concurrent JSZip stream when one read breaches its bound', async () => {
|
|
205
|
+
const data = await makeZip(
|
|
206
|
+
{
|
|
207
|
+
'one.bin': new Uint8Array(64 * 1024),
|
|
208
|
+
'two.bin': new Uint8Array(64 * 1024),
|
|
209
|
+
},
|
|
210
|
+
'STORE',
|
|
211
|
+
);
|
|
212
|
+
const archive = await openBoundedZipArchive(data);
|
|
213
|
+
const pauseSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
214
|
+
|
|
215
|
+
for (const metadata of archive.entries) {
|
|
216
|
+
const entry = metadata.entry as unknown as StreamableEntry;
|
|
217
|
+
const original = entry.internalStream.bind(entry);
|
|
218
|
+
entry.internalStream = ((type: 'uint8array') => {
|
|
219
|
+
const stream = original(type);
|
|
220
|
+
const pause = vi.fn(stream.pause.bind(stream));
|
|
221
|
+
stream.pause = pause;
|
|
222
|
+
pauseSpies.push(pause);
|
|
223
|
+
return stream;
|
|
224
|
+
}) as StreamableEntry['internalStream'];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const results = await Promise.allSettled([
|
|
228
|
+
archive.read('one.bin', 1),
|
|
229
|
+
archive.read('two.bin', 1),
|
|
230
|
+
]);
|
|
231
|
+
expect(results.every((result) => result.status === 'rejected')).toBe(true);
|
|
232
|
+
expect(pauseSpies).toHaveLength(2);
|
|
233
|
+
expect(pauseSpies.every((pause) => pause.mock.calls.length > 0)).toBe(true);
|
|
234
|
+
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('caches successful part reads without charging the aggregate budget twice', async () => {
|
|
238
|
+
const data = await makeZip({ 'part.xml': '<part />' }, 'STORE');
|
|
239
|
+
const archive = await openBoundedZipArchive(data);
|
|
240
|
+
const first = await archive.read('part.xml');
|
|
241
|
+
const emittedAfterFirstRead = archive.emittedUncompressedBytes;
|
|
242
|
+
const second = await archive.read('part.xml');
|
|
243
|
+
|
|
244
|
+
expect(second).toBe(first);
|
|
245
|
+
expect(archive.emittedUncompressedBytes).toBe(emittedAfterFirstRead);
|
|
246
|
+
expect(emittedAfterFirstRead).toBe(new TextEncoder().encode('<part />').byteLength);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('checks CRC32 while streaming instead of eagerly inflating during load', async () => {
|
|
250
|
+
const valid = await makeZip({ 'content.txt': 'integrity' }, 'STORE');
|
|
251
|
+
const forged = patchMemberMetadata(valid, 'content.txt', { crc32: 0 });
|
|
252
|
+
const archive = await openBoundedZipArchive(forged);
|
|
253
|
+
|
|
254
|
+
await expect(archive.read('content.txt')).rejects.toMatchObject({
|
|
255
|
+
code: 'crc-mismatch',
|
|
256
|
+
path: 'content.txt',
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not reject an otherwise valid JSZip object solely for unavailable internals', async () => {
|
|
261
|
+
const data = await makeZip({ 'content.txt': 'okay' }, 'STORE');
|
|
262
|
+
const zip = await JSZip.loadAsync(data);
|
|
263
|
+
const entry = zip.file('content.txt') as unknown as {
|
|
264
|
+
_data: { uncompressedSize?: number; compressedSize?: number; crc32?: number };
|
|
265
|
+
};
|
|
266
|
+
delete entry._data.uncompressedSize;
|
|
267
|
+
delete entry._data.compressedSize;
|
|
268
|
+
delete entry._data.crc32;
|
|
269
|
+
|
|
270
|
+
expect(() => validateZipArchive(zip)).not.toThrow();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects manually constructed OOXML packages that bypass bounded archive opening', async () => {
|
|
274
|
+
const forged = {
|
|
275
|
+
contentTypes: { overrides: new Map(), defaults: new Map() },
|
|
276
|
+
rootRelationships: [],
|
|
277
|
+
} as unknown as OoxmlPackage;
|
|
278
|
+
|
|
279
|
+
await expect(getPartXml(forged, 'custom.xml')).rejects.toThrow(
|
|
280
|
+
'Invalid OoxmlPackage: create packages with openPackage().',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('bounds mandatory OOXML metadata independently of large media allowances', async () => {
|
|
285
|
+
const oversizedContentTypes = `<Types>${' '.repeat(1024 * 1024)}</Types>`;
|
|
286
|
+
const data = await makeZip(
|
|
287
|
+
{
|
|
288
|
+
'[Content_Types].xml': oversizedContentTypes,
|
|
289
|
+
'word/media/large.bin': new Uint8Array(2 * 1024 * 1024),
|
|
290
|
+
},
|
|
291
|
+
'STORE',
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
295
|
+
code: 'entry-too-large',
|
|
296
|
+
path: '[Content_Types].xml',
|
|
297
|
+
limit: 1024 * 1024,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('bounds OOXML relationship metadata before DOM parsing', async () => {
|
|
302
|
+
const oversizedRelationships = `<Relationships>${' '.repeat(4 * 1024 * 1024)}</Relationships>`;
|
|
303
|
+
const data = await makeZip(
|
|
304
|
+
{
|
|
305
|
+
'[Content_Types].xml': '<Types />',
|
|
306
|
+
'_rels/.rels': oversizedRelationships,
|
|
307
|
+
},
|
|
308
|
+
'STORE',
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
312
|
+
code: 'entry-too-large',
|
|
313
|
+
path: '_rels/.rels',
|
|
314
|
+
limit: 4 * 1024 * 1024,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
package/src/container/index.ts
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
import JSZip from 'jszip';
|
|
15
15
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
16
16
|
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
17
|
+
import {
|
|
18
|
+
assertSafeZipPath,
|
|
19
|
+
openBoundedZipArchive,
|
|
20
|
+
ZipSafetyError,
|
|
21
|
+
type ZipSafetyLimits,
|
|
22
|
+
type ZipSafetyErrorCode,
|
|
23
|
+
type ZipSafetyErrorOptions,
|
|
24
|
+
} from '../shared/zipSafety.js';
|
|
25
|
+
|
|
26
|
+
export type ZipToContainerOptions = ZipSafetyLimits;
|
|
27
|
+
export { ZipSafetyError };
|
|
28
|
+
export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
17
29
|
|
|
18
30
|
/**
|
|
19
31
|
* Serialize a ContentContainer to a ZIP blob.
|
|
@@ -29,6 +41,7 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
|
|
|
29
41
|
const entries = await container.listFiles();
|
|
30
42
|
|
|
31
43
|
for (const entry of entries) {
|
|
44
|
+
assertSafeZipPath(entry.path);
|
|
32
45
|
const data = await container.readFile(entry.path);
|
|
33
46
|
if (data) {
|
|
34
47
|
zip.file(entry.path, new Uint8Array(data));
|
|
@@ -54,36 +67,14 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
|
|
|
54
67
|
*/
|
|
55
68
|
export async function zipToContainer(
|
|
56
69
|
zipData: ArrayBuffer | Uint8Array | Blob,
|
|
70
|
+
options: ZipToContainerOptions = {},
|
|
57
71
|
): Promise<MemoryContentContainer> {
|
|
58
|
-
const
|
|
72
|
+
const archive = await openBoundedZipArchive(zipData, options);
|
|
59
73
|
const container = new MemoryContentContainer();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (zipEntry.dir) return;
|
|
66
|
-
|
|
67
|
-
// Strip leading slash if present
|
|
68
|
-
const entryPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
69
|
-
if (!entryPath) return;
|
|
70
|
-
|
|
71
|
-
// Path-traversal protection: reject absolute paths, backslashes, and .. segments
|
|
72
|
-
if (
|
|
73
|
-
entryPath.startsWith('/') ||
|
|
74
|
-
entryPath.includes('\\') ||
|
|
75
|
-
entryPath.split('/').some((seg) => seg === '..')
|
|
76
|
-
) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
filePromises.push(
|
|
81
|
-
zipEntry.async('arraybuffer').then((data) => {
|
|
82
|
-
return container.writeFile(entryPath, data);
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await Promise.all(filePromises);
|
|
74
|
+
for (const { path } of archive.entries) {
|
|
75
|
+
const data = await archive.read(path);
|
|
76
|
+
if (data) await container.writeFile(path, data);
|
|
77
|
+
archive.release(path);
|
|
78
|
+
}
|
|
88
79
|
return container;
|
|
89
80
|
}
|
package/src/docx/export.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { Doc, Theme } from '@bendyline/squisq/schemas';
|
|
20
|
+
import type { Doc, Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
21
21
|
import { resolveFontFamily } from '@bendyline/squisq/schemas';
|
|
22
22
|
import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
23
23
|
import type {
|
|
@@ -105,6 +105,8 @@ export interface DocxExportOptions {
|
|
|
105
105
|
* the theme's primary color to headings.
|
|
106
106
|
*/
|
|
107
107
|
themeId?: string;
|
|
108
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
109
|
+
themeRegistry?: ThemeRegistry;
|
|
108
110
|
/**
|
|
109
111
|
* Pre-resolved image data keyed by image URL/path as it appears in the
|
|
110
112
|
* markdown source. When provided, images are embedded in the .docx file
|
|
@@ -221,7 +223,7 @@ class ExportContext {
|
|
|
221
223
|
let themeBackgroundColor: string | undefined;
|
|
222
224
|
|
|
223
225
|
if (options.themeId) {
|
|
224
|
-
const theme: Theme = resolveThemeForDoc(doc, options.themeId);
|
|
226
|
+
const theme: Theme = resolveThemeForDoc(doc, options.themeId, options.themeRegistry);
|
|
225
227
|
// Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
|
|
226
228
|
// "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
|
|
227
229
|
// single font name — passing the whole stack is treated as a
|
package/src/docx/import.ts
CHANGED
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
} from '@bendyline/squisq/markdown';
|
|
49
49
|
|
|
50
50
|
import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
|
|
51
|
+
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
51
52
|
import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
|
|
52
53
|
import { NS_WML, NS_R } from '../ooxml/namespaces.js';
|
|
53
54
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
@@ -68,7 +69,7 @@ import {
|
|
|
68
69
|
/**
|
|
69
70
|
* Options for DOCX import.
|
|
70
71
|
*/
|
|
71
|
-
export interface DocxImportOptions {
|
|
72
|
+
export interface DocxImportOptions extends OoxmlOpenOptions {
|
|
72
73
|
/**
|
|
73
74
|
* Whether to extract embedded images as base64 data URIs.
|
|
74
75
|
* When false, images are represented as `[Image]` placeholders.
|
|
@@ -88,7 +89,7 @@ export async function docxToMarkdownDoc(
|
|
|
88
89
|
data: ArrayBuffer | Blob,
|
|
89
90
|
options: DocxImportOptions = {},
|
|
90
91
|
): Promise<MarkdownDocument> {
|
|
91
|
-
const pkg = await openPackage(data);
|
|
92
|
+
const pkg = await openPackage(data, options);
|
|
92
93
|
const ctx = await buildImportContext(pkg, options);
|
|
93
94
|
|
|
94
95
|
const documentXml = await getPartXml(pkg, 'word/document.xml');
|
|
@@ -138,7 +139,7 @@ export async function docxToContainer(
|
|
|
138
139
|
data: ArrayBuffer | Blob,
|
|
139
140
|
options: DocxImportOptions = {},
|
|
140
141
|
): Promise<ContentContainer> {
|
|
141
|
-
const pkg = await openPackage(data);
|
|
142
|
+
const pkg = await openPackage(data, options);
|
|
142
143
|
const ctx = await buildImportContext(pkg, { ...options, extractImages: true });
|
|
143
144
|
|
|
144
145
|
const documentXml = await getPartXml(pkg, 'word/document.xml');
|
package/src/epub/export.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import JSZip from 'jszip';
|
|
25
|
-
import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
|
|
25
|
+
import type { Doc, AudioSegment, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
26
26
|
import { resolveFontFamily } from '@bendyline/squisq/schemas';
|
|
27
27
|
import { resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
28
28
|
import type {
|
|
@@ -36,7 +36,7 @@ import type {
|
|
|
36
36
|
MarkdownTableRow,
|
|
37
37
|
MarkdownTableCell,
|
|
38
38
|
} from '@bendyline/squisq/markdown';
|
|
39
|
-
import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
|
|
39
|
+
import { readFrontmatterThemeId, sanitizeUrl } from '@bendyline/squisq/markdown';
|
|
40
40
|
import { escapeXml } from '../ooxml/xmlUtils.js';
|
|
41
41
|
import { inferMimeType, extractFilename } from '../html/imageUtils.js';
|
|
42
42
|
import { extractPlainText } from '../shared/text.js';
|
|
@@ -56,6 +56,8 @@ export interface EpubExportOptions {
|
|
|
56
56
|
publisher?: string;
|
|
57
57
|
/** Squisq theme ID for CSS styling */
|
|
58
58
|
themeId?: string;
|
|
59
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
60
|
+
themeRegistry?: ThemeRegistry;
|
|
59
61
|
/** Pre-resolved image data keyed by relative path as it appears in the markdown */
|
|
60
62
|
images?: Map<string, ArrayBuffer>;
|
|
61
63
|
/** Cover image data (JPEG or PNG) */
|
|
@@ -93,7 +95,7 @@ export async function markdownDocToEpub(
|
|
|
93
95
|
const language = options.language ?? 'en';
|
|
94
96
|
const description = options.description ?? '';
|
|
95
97
|
const publisher = options.publisher ?? '';
|
|
96
|
-
const uuid =
|
|
98
|
+
const uuid = createEpubUuid();
|
|
97
99
|
|
|
98
100
|
// Split document into chapters
|
|
99
101
|
const chapters = splitIntoChapters(doc.children);
|
|
@@ -101,22 +103,13 @@ export async function markdownDocToEpub(
|
|
|
101
103
|
// Collect images referenced in the document, deduplicating filenames
|
|
102
104
|
const imageEntries = collectDocImages(doc.children);
|
|
103
105
|
const resolvedImages = new Map<string, { data: ArrayBuffer; mime: string; filename: string }>();
|
|
106
|
+
const usedImageNames = new Set<string>();
|
|
104
107
|
if (options.images) {
|
|
105
|
-
const usedNames = new Set<string>();
|
|
106
108
|
for (const src of imageEntries) {
|
|
107
109
|
const data = options.images.get(src);
|
|
108
110
|
if (data) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (usedNames.has(filename)) {
|
|
112
|
-
const dot = filename.lastIndexOf('.');
|
|
113
|
-
const base = dot > 0 ? filename.slice(0, dot) : filename;
|
|
114
|
-
const ext = dot > 0 ? filename.slice(dot) : '';
|
|
115
|
-
let counter = 2;
|
|
116
|
-
while (usedNames.has(`${base}-${counter}${ext}`)) counter++;
|
|
117
|
-
filename = `${base}-${counter}${ext}`;
|
|
118
|
-
}
|
|
119
|
-
usedNames.add(filename);
|
|
111
|
+
const filename = uniqueFilename(safeArchiveBasename(src, 'image'), usedImageNames);
|
|
112
|
+
usedImageNames.add(filename);
|
|
120
113
|
resolvedImages.set(src, { data, mime: inferMimeType(filename), filename });
|
|
121
114
|
}
|
|
122
115
|
}
|
|
@@ -124,7 +117,11 @@ export async function markdownDocToEpub(
|
|
|
124
117
|
|
|
125
118
|
// Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
|
|
126
119
|
// theme (`squisq-theme` / legacy), mirroring the other export formats.
|
|
127
|
-
const css = generateStylesheet(
|
|
120
|
+
const css = generateStylesheet(
|
|
121
|
+
options.themeId ?? readFrontmatterThemeId(doc.frontmatter),
|
|
122
|
+
doc,
|
|
123
|
+
options.themeRegistry,
|
|
124
|
+
);
|
|
128
125
|
|
|
129
126
|
// Build the ZIP
|
|
130
127
|
const zip = new JSZip();
|
|
@@ -155,7 +152,8 @@ export async function markdownDocToEpub(
|
|
|
155
152
|
bytes[1] === 0x50 &&
|
|
156
153
|
bytes[2] === 0x4e &&
|
|
157
154
|
bytes[3] === 0x47;
|
|
158
|
-
coverFilename = isPng ? 'cover.png' : 'cover.jpg';
|
|
155
|
+
coverFilename = uniqueFilename(isPng ? 'cover.png' : 'cover.jpg', usedImageNames);
|
|
156
|
+
usedImageNames.add(coverFilename);
|
|
159
157
|
zip.file(`OEBPS/images/${coverFilename}`, options.coverImage);
|
|
160
158
|
|
|
161
159
|
// Generate cover XHTML page — full-bleed image, no margins
|
|
@@ -184,17 +182,28 @@ export async function markdownDocToEpub(
|
|
|
184
182
|
// Per-segment audio file info, indexed by segment index (null if data missing)
|
|
185
183
|
const segmentAudioFiles: ({ filename: string; mime: string } | null)[] = [];
|
|
186
184
|
const allAudioFiles: { filename: string; mime: string }[] = [];
|
|
185
|
+
const audioFilenameBySource = new Map<string, string>();
|
|
186
|
+
const usedAudioFilenames = new Set<string>();
|
|
187
187
|
|
|
188
188
|
if (hasAudio) {
|
|
189
189
|
for (const seg of audioSegments) {
|
|
190
190
|
const data = audioMap.get(seg.src) ?? audioMap.get(seg.name);
|
|
191
191
|
if (data) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
const sourceKey = seg.src || seg.name;
|
|
193
|
+
let finalName = audioFilenameBySource.get(sourceKey);
|
|
194
|
+
let isNewFile = false;
|
|
195
|
+
if (!finalName) {
|
|
196
|
+
const basename = safeArchiveBasename(sourceKey || 'narration', 'narration');
|
|
197
|
+
const withExtension = basename.includes('.') ? basename : `${basename}.mp3`;
|
|
198
|
+
finalName = uniqueFilename(withExtension, usedAudioFilenames);
|
|
199
|
+
audioFilenameBySource.set(sourceKey, finalName);
|
|
200
|
+
usedAudioFilenames.add(finalName);
|
|
201
|
+
zip.file(`OEBPS/audio/${finalName}`, data);
|
|
202
|
+
isNewFile = true;
|
|
203
|
+
}
|
|
195
204
|
const info = { filename: finalName, mime: inferMimeType(finalName) };
|
|
196
205
|
segmentAudioFiles.push(info);
|
|
197
|
-
allAudioFiles.push(info);
|
|
206
|
+
if (isNewFile) allAudioFiles.push(info);
|
|
198
207
|
} else {
|
|
199
208
|
segmentAudioFiles.push(null);
|
|
200
209
|
}
|
|
@@ -535,14 +544,18 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
|
|
|
535
544
|
return `<code>${escapeXml(node.value)}</code>`;
|
|
536
545
|
|
|
537
546
|
case 'link': {
|
|
547
|
+
const href = sanitizeUrl(node.url, 'link');
|
|
548
|
+
if (!href) return inlinesToXhtml(node.children, images);
|
|
538
549
|
const titleAttr = node.title ? ` title="${escapeXml(node.title)}"` : '';
|
|
539
|
-
return `<a href="${escapeXml(
|
|
550
|
+
return `<a href="${escapeXml(href)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
|
|
540
551
|
}
|
|
541
552
|
|
|
542
553
|
case 'image': {
|
|
543
554
|
const alt = escapeXml(node.alt ?? '');
|
|
544
555
|
const resolved = images.get(node.url);
|
|
545
|
-
const
|
|
556
|
+
const safeSrc = resolved ? `../images/${resolved.filename}` : sanitizeUrl(node.url, 'media');
|
|
557
|
+
if (!safeSrc) return alt;
|
|
558
|
+
const src = escapeXml(safeSrc);
|
|
546
559
|
return `<img src="${src}" alt="${alt}"/>`;
|
|
547
560
|
}
|
|
548
561
|
|
|
@@ -561,6 +574,58 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
|
|
|
561
574
|
}
|
|
562
575
|
}
|
|
563
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Produce an EPUB identifier without assuming `crypto.randomUUID()` exists.
|
|
579
|
+
* `randomUUID` is unavailable in older/non-secure browser contexts; use
|
|
580
|
+
* `getRandomValues` when possible and retain a non-cryptographic last resort
|
|
581
|
+
* because this UUID is an identifier, not a security token.
|
|
582
|
+
*/
|
|
583
|
+
function createEpubUuid(): string {
|
|
584
|
+
const webCrypto = globalThis.crypto;
|
|
585
|
+
if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID();
|
|
586
|
+
|
|
587
|
+
const bytes = new Uint8Array(16);
|
|
588
|
+
if (typeof webCrypto?.getRandomValues === 'function') {
|
|
589
|
+
webCrypto.getRandomValues(bytes);
|
|
590
|
+
} else {
|
|
591
|
+
let seed = Date.now() ^ Math.floor(Math.random() * 0x7fffffff);
|
|
592
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
593
|
+
seed = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
594
|
+
bytes[i] = seed & 0xff;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// RFC 4122 version 4 / variant 1 bits.
|
|
598
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
599
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
600
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
601
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Flatten an external path to a safe archive basename. */
|
|
605
|
+
function safeArchiveBasename(path: string, fallback: string): string {
|
|
606
|
+
const filename = extractFilename(path.replace(/\\/g, '/'));
|
|
607
|
+
const safe = Array.from(filename, (character) => {
|
|
608
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
609
|
+
return character === '\\' || character === '/' || codePoint < 0x20 || codePoint === 0x7f
|
|
610
|
+
? '_'
|
|
611
|
+
: character;
|
|
612
|
+
})
|
|
613
|
+
.join('')
|
|
614
|
+
.trim();
|
|
615
|
+
return !safe || safe === '.' || safe === '..' ? fallback : safe;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Return a basename that does not collide with a prior archive member. */
|
|
619
|
+
function uniqueFilename(filename: string, used: ReadonlySet<string>): string {
|
|
620
|
+
if (!used.has(filename)) return filename;
|
|
621
|
+
const dot = filename.lastIndexOf('.');
|
|
622
|
+
const base = dot > 0 ? filename.slice(0, dot) : filename;
|
|
623
|
+
const ext = dot > 0 ? filename.slice(dot) : '';
|
|
624
|
+
let suffix = 2;
|
|
625
|
+
while (used.has(`${base}-${suffix}${ext}`)) suffix++;
|
|
626
|
+
return `${base}-${suffix}${ext}`;
|
|
627
|
+
}
|
|
628
|
+
|
|
564
629
|
// ── SMIL Media Overlays ───────────────────────────────────────────
|
|
565
630
|
|
|
566
631
|
/**
|
|
@@ -778,10 +843,14 @@ ${navItems}
|
|
|
778
843
|
|
|
779
844
|
// ── Stylesheet ────────────────────────────────────────────────────
|
|
780
845
|
|
|
781
|
-
function generateStylesheet(
|
|
846
|
+
function generateStylesheet(
|
|
847
|
+
themeId: string | undefined,
|
|
848
|
+
doc?: MarkdownDocument,
|
|
849
|
+
registry?: ThemeRegistry,
|
|
850
|
+
): string {
|
|
782
851
|
let themeVars = '';
|
|
783
852
|
if (themeId) {
|
|
784
|
-
const theme = resolveThemeForDoc(doc, themeId);
|
|
853
|
+
const theme = resolveThemeForDoc(doc, themeId, registry);
|
|
785
854
|
themeVars = `
|
|
786
855
|
--epub-bg: ${theme.colors.background};
|
|
787
856
|
--epub-text: ${theme.colors.text};
|