@bendyline/squisq-formats 1.4.2 → 2.0.1
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-2KPARF2P.js +99 -0
- package/dist/chunk-2KPARF2P.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-ABVI556T.js} +154 -39
- package/dist/chunk-ABVI556T.js.map +1 -0
- package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
- package/dist/chunk-CRAVSMPZ.js.map +1 -0
- package/dist/chunk-FE6OJV6O.js +560 -0
- package/dist/chunk-FE6OJV6O.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-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
- package/dist/chunk-HTW2M27H.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-QRVN6A6E.js +127 -0
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/chunk-U32AG3G3.js +195 -0
- package/dist/chunk-U32AG3G3.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
- package/dist/chunk-VJJM2SSH.js.map +1 -0
- package/dist/chunk-VSYHZECT.js +778 -0
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/chunk-XKUMNGBW.js +131 -0
- package/dist/chunk-XKUMNGBW.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/container/index.d.ts +13 -3
- package/dist/container/index.js +23 -20
- 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-D2NkylDT.d.ts +69 -0
- package/dist/extract-H6RXJMHP.js +15 -0
- package/dist/extract-H6RXJMHP.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-K8mfc0fz.d.ts +136 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -12
- 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-BHrgZ5FS.d.ts +150 -0
- package/dist/layouts-QVPK3ZCU.js +12 -0
- package/dist/layouts-QVPK3ZCU.js.map +1 -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-B9L8Ucbj.d.ts +140 -0
- package/dist/registry/index.d.ts +60 -8
- package/dist/registry/index.js +9 -4
- package/dist/themeReader-DJKErl_j.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +310 -1
- 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 +76 -2
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- 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 +37 -32
- 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 +23 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +199 -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 +129 -29
- 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 +1222 -0
- package/src/registry/convert.ts +168 -53
- package/src/registry/defaultFormats.ts +135 -45
- package/src/registry/index.ts +6 -1
- package/src/registry/types.ts +69 -6
- package/src/shared/boundedZipArchive.ts +383 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +151 -0
- package/src/shared/zipLimits.ts +296 -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
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import JSZip from 'jszip';
|
|
8
9
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
9
10
|
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
10
|
-
import {
|
|
11
|
+
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
12
|
+
import { createTransformStyleRegistry, resolveTransformStyle } from '@bendyline/squisq/transform';
|
|
13
|
+
import { convert, ConversionError, defaultRegistry, prepareConversion } from '../registry/index';
|
|
14
|
+
import { zipToContainer } from '../container/index';
|
|
15
|
+
import { markdownDocToPptx } from '../pptx/export';
|
|
16
|
+
import { buildThemedPptx } from './pptxInferFixtures';
|
|
11
17
|
|
|
12
18
|
const SAMPLE_MD = `# Round Trip
|
|
13
19
|
|
|
@@ -40,6 +46,105 @@ describe('convert error paths', () => {
|
|
|
40
46
|
convert({ kind: 'markdown', markdown: '# hi' }, 'importonly', { registry }),
|
|
41
47
|
).rejects.toMatchObject({ name: 'ConversionError', code: 'unsupported-output' });
|
|
42
48
|
});
|
|
49
|
+
|
|
50
|
+
it('normalizes importer failures to ConversionError', async () => {
|
|
51
|
+
const promise = convert({ kind: 'bytes', data: new TextEncoder().encode('not a zip') }, 'md', {
|
|
52
|
+
from: 'docx',
|
|
53
|
+
});
|
|
54
|
+
await expect(promise).rejects.toMatchObject({
|
|
55
|
+
name: 'ConversionError',
|
|
56
|
+
code: 'invalid-input',
|
|
57
|
+
format: 'docx',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('threads cancellation to exporters and preserves the abort reason', async () => {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const reason = new Error('caller cancelled conversion');
|
|
64
|
+
reason.name = 'AbortError';
|
|
65
|
+
const registry = defaultRegistry();
|
|
66
|
+
registry.register({
|
|
67
|
+
id: 'abortable',
|
|
68
|
+
label: 'Abortable',
|
|
69
|
+
mimeType: 'application/x-abortable',
|
|
70
|
+
extensions: ['.abortable'],
|
|
71
|
+
async exportDoc(_input, options) {
|
|
72
|
+
expect(options.signal).toBe(controller.signal);
|
|
73
|
+
controller.abort(reason);
|
|
74
|
+
throw new Error('must not replace the abort reason');
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
convert({ kind: 'markdown', markdown: '# Cancel me' }, 'abortable', {
|
|
80
|
+
registry,
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toBe(reason);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('format option threading', () => {
|
|
88
|
+
it('threads CSV tableIndex and delimiter through the conversion facade', async () => {
|
|
89
|
+
const markdown =
|
|
90
|
+
'| A | B |\n|---|---|\n| first | row |\n\n| C | D |\n|---|---|\n| second | row |\n';
|
|
91
|
+
const result = await convert({ kind: 'markdown', markdown }, 'csv', {
|
|
92
|
+
formatOptions: { csv: { tableIndex: 1, delimiter: ';' } },
|
|
93
|
+
});
|
|
94
|
+
expect(new TextDecoder().decode(result.bytes)).toBe('C;D\r\nsecond;row');
|
|
95
|
+
expect(result.warnings).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('honors Markdown parse options for direct Markdown sources', async () => {
|
|
99
|
+
let captured: import('../registry/index').NormalizedInput | undefined;
|
|
100
|
+
const registry = defaultRegistry();
|
|
101
|
+
registry.register({
|
|
102
|
+
id: 'capture-md-options',
|
|
103
|
+
label: 'Capture Markdown Options',
|
|
104
|
+
mimeType: 'application/x-capture',
|
|
105
|
+
extensions: ['.capture'],
|
|
106
|
+
async exportDoc(input) {
|
|
107
|
+
captured = input;
|
|
108
|
+
return {
|
|
109
|
+
bytes: new Uint8Array(),
|
|
110
|
+
mimeType: 'application/x-capture',
|
|
111
|
+
suggestedFilename: '',
|
|
112
|
+
warnings: [],
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await convert(
|
|
118
|
+
{ kind: 'markdown', markdown: '---\ntitle: Hidden metadata\n---\n\n# Body' },
|
|
119
|
+
'capture-md-options',
|
|
120
|
+
{
|
|
121
|
+
registry,
|
|
122
|
+
formatOptions: { md: { parse: { frontmatter: false } } },
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(captured?.markdownDoc?.frontmatter).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('threads archive safety limits into OOXML importers', async () => {
|
|
130
|
+
const docx = await convert({ kind: 'markdown', markdown: '# Limited' }, 'docx');
|
|
131
|
+
await expect(
|
|
132
|
+
convert({ kind: 'bytes', data: docx.bytes }, 'md', {
|
|
133
|
+
from: 'docx',
|
|
134
|
+
formatOptions: { docx: { maxEntries: 1 } },
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toMatchObject({ code: 'invalid-input', format: 'docx' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('can emit DBK with STORE so strict import compression limits accept its own output', async () => {
|
|
140
|
+
const result = await convert(
|
|
141
|
+
{ kind: 'markdown', markdown: `# Self-compatible DBK\n\n${'x'.repeat(16 * 1024)}` },
|
|
142
|
+
'dbk',
|
|
143
|
+
{ formatOptions: { dbk: { compression: 'STORE' } } },
|
|
144
|
+
);
|
|
145
|
+
const restored = await zipToContainer(result.bytes, { maxCompressionRatio: 1.01 });
|
|
146
|
+
expect(await restored.readDocument()).toContain('Self-compatible DBK');
|
|
147
|
+
});
|
|
43
148
|
});
|
|
44
149
|
|
|
45
150
|
// ── round trip: md → docx → md ──────────────────────────────────────
|
|
@@ -94,6 +199,21 @@ describe('byte sniffing', () => {
|
|
|
94
199
|
expect(text).toContain('In a container');
|
|
95
200
|
});
|
|
96
201
|
|
|
202
|
+
it('DBK export snapshots the supplied source instead of stale container markdown', async () => {
|
|
203
|
+
const container = new MemoryContentContainer();
|
|
204
|
+
await container.writeDocument('# Old');
|
|
205
|
+
await container.writeFile('asset.bin', new Uint8Array([1, 2, 3]));
|
|
206
|
+
|
|
207
|
+
const result = await convert({ kind: 'markdown', markdown: '# New', container }, 'dbk');
|
|
208
|
+
const restored = await zipToContainer(result.bytes);
|
|
209
|
+
expect(await restored.readDocument()).toContain('# New');
|
|
210
|
+
expect(new Uint8Array((await restored.readFile('asset.bin'))!)).toEqual(
|
|
211
|
+
new Uint8Array([1, 2, 3]),
|
|
212
|
+
);
|
|
213
|
+
// Snapshotting must not mutate the caller's container.
|
|
214
|
+
expect(await container.readDocument()).toBe('# Old');
|
|
215
|
+
});
|
|
216
|
+
|
|
97
217
|
it('disambiguates a docx zip via its content-types part', async () => {
|
|
98
218
|
const docx = await convert({ kind: 'markdown', markdown: '# Word doc' }, 'docx');
|
|
99
219
|
// No filename, no `from` — must read [Content_Types].xml to pick docx.
|
|
@@ -106,6 +226,113 @@ describe('byte sniffing', () => {
|
|
|
106
226
|
// ── transform threading ─────────────────────────────────────────────
|
|
107
227
|
|
|
108
228
|
describe('transform threading', () => {
|
|
229
|
+
it('normalizes and transforms once before exporting multiple targets', async () => {
|
|
230
|
+
let importCount = 0;
|
|
231
|
+
let transformLookupCount = 0;
|
|
232
|
+
const exportCounts = { first: 0, second: 0 };
|
|
233
|
+
const markers: string[] = [];
|
|
234
|
+
const exportSignals: Array<AbortSignal | undefined> = [];
|
|
235
|
+
const formats = defaultRegistry();
|
|
236
|
+
formats.register({
|
|
237
|
+
id: 'counted-input',
|
|
238
|
+
label: 'Counted input',
|
|
239
|
+
mimeType: 'application/x-counted-input',
|
|
240
|
+
extensions: ['.counted'],
|
|
241
|
+
async importDoc() {
|
|
242
|
+
importCount += 1;
|
|
243
|
+
return parseMarkdown(SAMPLE_MD);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
for (const [id, key] of [
|
|
247
|
+
['prepared-first', 'first'],
|
|
248
|
+
['prepared-second', 'second'],
|
|
249
|
+
] as const) {
|
|
250
|
+
formats.register({
|
|
251
|
+
id,
|
|
252
|
+
label: id,
|
|
253
|
+
mimeType: `application/x-${id}`,
|
|
254
|
+
extensions: [`.${key}`],
|
|
255
|
+
async exportDoc(input, options) {
|
|
256
|
+
exportCounts[key] += 1;
|
|
257
|
+
exportSignals.push(options.signal);
|
|
258
|
+
const targetOptions = options.formatOptions?.[id] as { marker?: string } | undefined;
|
|
259
|
+
markers.push(targetOptions?.marker ?? 'missing');
|
|
260
|
+
expect(input.doc.blocks.length).toBeGreaterThan(0);
|
|
261
|
+
return {
|
|
262
|
+
bytes: new TextEncoder().encode(key),
|
|
263
|
+
mimeType: `application/x-${id}`,
|
|
264
|
+
suggestedFilename: '',
|
|
265
|
+
warnings: [`${key} warning`],
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const style = { ...resolveTransformStyle('magazine'), id: 'counted-transform' };
|
|
272
|
+
const backingTransforms = createTransformStyleRegistry([style]);
|
|
273
|
+
const transformRegistry = {
|
|
274
|
+
register: (candidate: typeof style) => backingTransforms.register(candidate),
|
|
275
|
+
unregister: (id: string) => backingTransforms.unregister(id),
|
|
276
|
+
get: (id: string) => {
|
|
277
|
+
transformLookupCount += 1;
|
|
278
|
+
return backingTransforms.get(id);
|
|
279
|
+
},
|
|
280
|
+
list: () => backingTransforms.list(),
|
|
281
|
+
};
|
|
282
|
+
const overall = new AbortController();
|
|
283
|
+
const prepared = await prepareConversion(
|
|
284
|
+
{
|
|
285
|
+
kind: 'bytes',
|
|
286
|
+
data: new TextEncoder().encode('count me once'),
|
|
287
|
+
filename: 'source.counted',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
registry: formats,
|
|
291
|
+
from: 'counted-input',
|
|
292
|
+
transformStyle: 'counted-transform',
|
|
293
|
+
transformRegistry,
|
|
294
|
+
signal: overall.signal,
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
const first = await prepared.convert('prepared-first', {
|
|
298
|
+
formatOptions: { 'prepared-first': { marker: 'one' } },
|
|
299
|
+
});
|
|
300
|
+
const target = new AbortController();
|
|
301
|
+
const second = await prepared.convert('prepared-second', {
|
|
302
|
+
signal: target.signal,
|
|
303
|
+
formatOptions: { 'prepared-second': { marker: 'two' } },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(importCount).toBe(1);
|
|
307
|
+
expect(transformLookupCount).toBe(1);
|
|
308
|
+
expect(exportCounts).toEqual({ first: 1, second: 1 });
|
|
309
|
+
expect(markers).toEqual(['one', 'two']);
|
|
310
|
+
expect(exportSignals[0]).toBe(overall.signal);
|
|
311
|
+
expect(exportSignals[1]?.aborted).toBe(false);
|
|
312
|
+
expect(first.suggestedFilename).toBe('source.first');
|
|
313
|
+
expect(second.suggestedFilename).toBe('source.second');
|
|
314
|
+
expect(first.warnings).toContain('first warning');
|
|
315
|
+
expect(second.warnings).toContain('second warning');
|
|
316
|
+
|
|
317
|
+
await expect(prepared.convert('missing-target')).rejects.toMatchObject({
|
|
318
|
+
name: 'ConversionError',
|
|
319
|
+
code: 'unknown-format',
|
|
320
|
+
format: 'missing-target',
|
|
321
|
+
});
|
|
322
|
+
const targetReason = new Error('cancel target export');
|
|
323
|
+
target.abort(targetReason);
|
|
324
|
+
expect(exportSignals[1]?.reason).toBe(targetReason);
|
|
325
|
+
await expect(prepared.convert('prepared-first', { signal: target.signal })).rejects.toBe(
|
|
326
|
+
targetReason,
|
|
327
|
+
);
|
|
328
|
+
const overallReason = new Error('cancel prepared conversion');
|
|
329
|
+
overall.abort(overallReason);
|
|
330
|
+
await expect(
|
|
331
|
+
prepared.convert('prepared-first', { signal: new AbortController().signal }),
|
|
332
|
+
).rejects.toBe(overallReason);
|
|
333
|
+
expect(exportCounts.first).toBe(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
109
336
|
it('applies a transform style before handing off to the exporter', async () => {
|
|
110
337
|
// A capture format records the NormalizedInput it receives, letting us
|
|
111
338
|
// observe that the transform ran without depending on any converter.
|
|
@@ -140,6 +367,40 @@ describe('transform threading', () => {
|
|
|
140
367
|
// markdownDoc is re-derived from the transformed Doc.
|
|
141
368
|
expect(captured!.markdownDoc).toBeDefined();
|
|
142
369
|
});
|
|
370
|
+
|
|
371
|
+
it('resolves transform ids only from the explicitly supplied registry', async () => {
|
|
372
|
+
let captured: import('../registry/index').NormalizedInput | undefined;
|
|
373
|
+
const formats = defaultRegistry();
|
|
374
|
+
formats.register({
|
|
375
|
+
id: 'capture-custom-transform',
|
|
376
|
+
label: 'Capture Custom Transform',
|
|
377
|
+
mimeType: 'application/x-capture',
|
|
378
|
+
extensions: ['.cap'],
|
|
379
|
+
async exportDoc(input) {
|
|
380
|
+
captured = input;
|
|
381
|
+
return {
|
|
382
|
+
bytes: new Uint8Array([1]),
|
|
383
|
+
mimeType: 'application/x-capture',
|
|
384
|
+
suggestedFilename: '',
|
|
385
|
+
warnings: [],
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const custom = {
|
|
390
|
+
...resolveTransformStyle('minimal'),
|
|
391
|
+
id: 'tenant-transform',
|
|
392
|
+
suggestedThemeId: 'cinematic',
|
|
393
|
+
};
|
|
394
|
+
const transformRegistry = createTransformStyleRegistry([custom]);
|
|
395
|
+
|
|
396
|
+
await convert({ kind: 'markdown', markdown: SAMPLE_MD }, 'capture-custom-transform', {
|
|
397
|
+
registry: formats,
|
|
398
|
+
transformStyle: 'tenant-transform',
|
|
399
|
+
transformRegistry,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(captured?.doc.themeId).toBe('cinematic');
|
|
403
|
+
});
|
|
143
404
|
});
|
|
144
405
|
|
|
145
406
|
// ── doc-kind source ─────────────────────────────────────────────────
|
|
@@ -178,9 +439,57 @@ describe('suggestedFilename', () => {
|
|
|
178
439
|
const out = await convert({ kind: 'markdown', markdown: '# t' }, 'md');
|
|
179
440
|
expect(out.suggestedFilename).toBe('document.md');
|
|
180
441
|
});
|
|
442
|
+
|
|
443
|
+
it('uses only the basename for Windows-style input paths', async () => {
|
|
444
|
+
const out = await convert(
|
|
445
|
+
{
|
|
446
|
+
kind: 'bytes',
|
|
447
|
+
data: new TextEncoder().encode('# Windows'),
|
|
448
|
+
filename: 'C:\\docs\\report.md',
|
|
449
|
+
},
|
|
450
|
+
'md',
|
|
451
|
+
);
|
|
452
|
+
expect(out.suggestedFilename).toBe('report.md');
|
|
453
|
+
});
|
|
181
454
|
});
|
|
182
455
|
|
|
183
456
|
// sanity: ConversionError is exported and usable
|
|
457
|
+
// ── pptx theme/layout inference threading ───────────────────────────
|
|
458
|
+
|
|
459
|
+
describe('pptx import inference threading (default ON)', () => {
|
|
460
|
+
it('carries an inferred theme through convert() by default', async () => {
|
|
461
|
+
const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
|
|
462
|
+
const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md');
|
|
463
|
+
const md = new TextDecoder().decode(result.bytes);
|
|
464
|
+
expect(md).toContain('squisq-theme: custom-office-theme');
|
|
465
|
+
expect(md).toContain('squisq-custom-themes');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('omits inference with formatOptions.pptx.inferTheme=false', async () => {
|
|
469
|
+
const deck = await markdownDocToPptx(parseMarkdown('# Hi\n\nBody.\n'), {});
|
|
470
|
+
const result = await convert({ kind: 'bytes', data: deck, filename: 'deck.pptx' }, 'md', {
|
|
471
|
+
formatOptions: { pptx: { inferTheme: false, inferLayouts: false } },
|
|
472
|
+
});
|
|
473
|
+
const md = new TextDecoder().decode(result.bytes);
|
|
474
|
+
expect(md).not.toContain('squisq-theme');
|
|
475
|
+
expect(md).not.toContain('squisq-custom-themes');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('end-to-end: inferred theme colors reach a downstream docx export', async () => {
|
|
479
|
+
// Distinctive fixture theme (bg #fdfdf8, text #1a1a2e) → pptx → docx.
|
|
480
|
+
const result = await convert(
|
|
481
|
+
{ kind: 'bytes', data: await buildThemedPptx(), filename: 'deck.pptx' },
|
|
482
|
+
'docx',
|
|
483
|
+
);
|
|
484
|
+
const zip = await JSZip.loadAsync(result.bytes);
|
|
485
|
+
let text = '';
|
|
486
|
+
for (const name of Object.keys(zip.files)) {
|
|
487
|
+
if (/\.(xml|rels)$/i.test(name)) text += await zip.files[name]!.async('string');
|
|
488
|
+
}
|
|
489
|
+
expect(text.toLowerCase()).toContain('1a1a2e');
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
184
493
|
it('exports ConversionError', () => {
|
|
185
494
|
expect(new ConversionError('conversion-failed', 'x')).toBeInstanceOf(Error);
|
|
186
495
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* image embedding, metadata, and stylesheet generation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import type { MarkdownDocument, MarkdownBlockNode } from '@bendyline/squisq/markdown';
|
|
11
11
|
import { markdownDocToEpub } from '../epub/export';
|
|
@@ -65,6 +65,10 @@ async function exportAndUnzip(doc: MarkdownDocument, options?: EpubExportOptions
|
|
|
65
65
|
// ============================================
|
|
66
66
|
|
|
67
67
|
describe('markdownDocToEpub', () => {
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.unstubAllGlobals();
|
|
70
|
+
});
|
|
71
|
+
|
|
68
72
|
describe('EPUB structure', () => {
|
|
69
73
|
it('produces a valid ZIP with required EPUB files', async () => {
|
|
70
74
|
const doc = makeDoc([heading(1, 'Title'), paragraph('Content')]);
|
|
@@ -175,6 +179,15 @@ describe('markdownDocToEpub', () => {
|
|
|
175
179
|
expect(ch).toContain('<a href="https://example.com">click</a>');
|
|
176
180
|
});
|
|
177
181
|
|
|
182
|
+
it('strips executable link schemes while preserving link text', async () => {
|
|
183
|
+
const doc = makeDoc([heading(1, 'Test'), linkParagraph('click', 'javascript:alert(1)')]);
|
|
184
|
+
const zip = await exportAndUnzip(doc);
|
|
185
|
+
const ch = await zip.file('OEBPS/chapters/chapter-001.xhtml')!.async('string');
|
|
186
|
+
expect(ch).not.toContain('javascript:');
|
|
187
|
+
expect(ch).not.toContain('<a ');
|
|
188
|
+
expect(ch).toContain('click');
|
|
189
|
+
});
|
|
190
|
+
|
|
178
191
|
it('renders code blocks with language class', async () => {
|
|
179
192
|
const doc = makeDoc([heading(1, 'Test'), codeBlock('const x = 1;', 'javascript')]);
|
|
180
193
|
const zip = await exportAndUnzip(doc);
|
|
@@ -298,6 +311,17 @@ describe('markdownDocToEpub', () => {
|
|
|
298
311
|
expect(opf).toContain('image/png');
|
|
299
312
|
});
|
|
300
313
|
|
|
314
|
+
it('flattens backslash-separated image paths to safe archive basenames', async () => {
|
|
315
|
+
const source = '..\\private\\logo.png';
|
|
316
|
+
const doc = makeDoc([heading(1, 'Test'), imageParagraph('Logo', source)]);
|
|
317
|
+
const zip = await exportAndUnzip(doc, {
|
|
318
|
+
images: new Map([[source, new Uint8Array([0x89]).buffer]]),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(zip.file('OEBPS/images/logo.png')).toBeTruthy();
|
|
322
|
+
expect(Object.keys(zip.files).some((path) => path.includes('\\'))).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
301
325
|
it('embeds cover image with cover-image property', async () => {
|
|
302
326
|
const coverData = new Uint8Array([0xff, 0xd8]).buffer;
|
|
303
327
|
const doc = makeDoc([heading(1, 'Test')]);
|
|
@@ -307,6 +331,23 @@ describe('markdownDocToEpub', () => {
|
|
|
307
331
|
const opf = await zip.file('OEBPS/content.opf')!.async('string');
|
|
308
332
|
expect(opf).toContain('cover-image');
|
|
309
333
|
});
|
|
334
|
+
|
|
335
|
+
it('does not overwrite a document image whose basename collides with the cover', async () => {
|
|
336
|
+
const documentImage = new Uint8Array([1, 2, 3]).buffer;
|
|
337
|
+
const coverImage = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
|
|
338
|
+
const doc = makeDoc([heading(1, 'Test'), imageParagraph('Inline cover', 'cover.png')]);
|
|
339
|
+
const zip = await exportAndUnzip(doc, {
|
|
340
|
+
images: new Map([['cover.png', documentImage]]),
|
|
341
|
+
coverImage,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(await zip.file('OEBPS/images/cover.png')!.async('uint8array')).toEqual(
|
|
345
|
+
new Uint8Array(documentImage),
|
|
346
|
+
);
|
|
347
|
+
expect(zip.file('OEBPS/images/cover-2.png')).toBeTruthy();
|
|
348
|
+
const opf = await zip.file('OEBPS/content.opf')!.async('string');
|
|
349
|
+
expect(opf).toContain('href="images/cover-2.png"');
|
|
350
|
+
});
|
|
310
351
|
});
|
|
311
352
|
|
|
312
353
|
describe('metadata', () => {
|
|
@@ -356,6 +397,18 @@ describe('markdownDocToEpub', () => {
|
|
|
356
397
|
expect(opf).toContain('urn:uuid:');
|
|
357
398
|
});
|
|
358
399
|
|
|
400
|
+
it('falls back to getRandomValues when randomUUID is unavailable', async () => {
|
|
401
|
+
vi.stubGlobal('crypto', {
|
|
402
|
+
getRandomValues<T extends ArrayBufferView>(array: T): T {
|
|
403
|
+
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(0xab);
|
|
404
|
+
return array;
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
const zip = await exportAndUnzip(makeDoc([heading(1, 'Test')]));
|
|
408
|
+
const opf = await zip.file('OEBPS/content.opf')!.async('string');
|
|
409
|
+
expect(opf).toMatch(/urn:uuid:abababab-abab-4bab-abab-abababababab/);
|
|
410
|
+
});
|
|
411
|
+
|
|
359
412
|
it('uses frontmatter title as fallback', async () => {
|
|
360
413
|
const doc = makeDoc([heading(1, 'Intro')], { title: 'Frontmatter Title' });
|
|
361
414
|
const zip = await exportAndUnzip(doc);
|
|
@@ -446,6 +499,54 @@ describe('markdownDocToEpub', () => {
|
|
|
446
499
|
expect(zip.file('OEBPS/audio/body.mp3')).toBeTruthy();
|
|
447
500
|
});
|
|
448
501
|
|
|
502
|
+
it('deduplicates colliding audio basenames without overwriting either segment', async () => {
|
|
503
|
+
const doc = makeDoc([
|
|
504
|
+
heading(1, 'One'),
|
|
505
|
+
paragraph('First'),
|
|
506
|
+
heading(1, 'Two'),
|
|
507
|
+
paragraph('Second'),
|
|
508
|
+
]);
|
|
509
|
+
const segments = [
|
|
510
|
+
{ src: 'a/narration.mp3', name: 'one', duration: 10, startTime: 0 },
|
|
511
|
+
{ src: 'b/narration.mp3', name: 'two', duration: 10, startTime: 10 },
|
|
512
|
+
];
|
|
513
|
+
const zip = await exportAndUnzip(doc, {
|
|
514
|
+
audio: new Map([
|
|
515
|
+
['a/narration.mp3', new Uint8Array([1]).buffer],
|
|
516
|
+
['b/narration.mp3', new Uint8Array([2]).buffer],
|
|
517
|
+
]),
|
|
518
|
+
audioSegments: segments,
|
|
519
|
+
totalDuration: 20,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
expect(zip.file('OEBPS/audio/narration.mp3')).toBeTruthy();
|
|
523
|
+
expect(zip.file('OEBPS/audio/narration-2.mp3')).toBeTruthy();
|
|
524
|
+
const firstSmil = await zip.file('OEBPS/chapters/chapter-001.smil')!.async('string');
|
|
525
|
+
const secondSmil = await zip.file('OEBPS/chapters/chapter-002.smil')!.async('string');
|
|
526
|
+
expect(firstSmil).toContain('narration.mp3');
|
|
527
|
+
expect(secondSmil).toContain('narration-2.mp3');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('writes one manifest item when multiple segments reuse one audio source', async () => {
|
|
531
|
+
const doc = makeDoc([
|
|
532
|
+
heading(1, 'One'),
|
|
533
|
+
paragraph('First'),
|
|
534
|
+
heading(1, 'Two'),
|
|
535
|
+
paragraph('Second'),
|
|
536
|
+
]);
|
|
537
|
+
const zip = await exportAndUnzip(doc, {
|
|
538
|
+
audio: new Map([['narration.mp3', new Uint8Array([1, 2, 3]).buffer]]),
|
|
539
|
+
audioSegments: [
|
|
540
|
+
{ src: 'narration.mp3', name: 'one', duration: 10, startTime: 0 },
|
|
541
|
+
{ src: 'narration.mp3', name: 'two', duration: 10, startTime: 10 },
|
|
542
|
+
],
|
|
543
|
+
totalDuration: 20,
|
|
544
|
+
});
|
|
545
|
+
const opf = await zip.file('OEBPS/content.opf')!.async('string');
|
|
546
|
+
|
|
547
|
+
expect(opf.match(/href="audio\/narration\.mp3"/g)).toHaveLength(1);
|
|
548
|
+
});
|
|
549
|
+
|
|
449
550
|
it('generates SMIL overlay files for each chapter', async () => {
|
|
450
551
|
const doc = makeDoc([
|
|
451
552
|
heading(1, 'Intro'),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
|
|
11
|
+
import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
|
|
11
12
|
import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
|
|
12
13
|
import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
|
|
13
14
|
|
|
@@ -95,6 +96,16 @@ describe('arrayBufferToBase64DataUrl', () => {
|
|
|
95
96
|
expect(result).toMatch(/^data:text\/plain;base64,/);
|
|
96
97
|
expect(result).toBe('data:text/plain;base64,aGVsbG8=');
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
it('encodes large buffers correctly across chunk boundaries', () => {
|
|
101
|
+
const bytes = Uint8Array.from({ length: 100_003 }, (_, index) => index % 251);
|
|
102
|
+
const result = arrayBufferToBase64DataUrl(bytes.buffer, 'application/octet-stream');
|
|
103
|
+
const decoded = Uint8Array.from(atob(result.split(',')[1]), (character) =>
|
|
104
|
+
character.charCodeAt(0),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(decoded).toEqual(bytes);
|
|
108
|
+
});
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
describe('extractFilename', () => {
|
|
@@ -225,6 +236,41 @@ describe('docToHtml', () => {
|
|
|
225
236
|
expect(html).toContain('test-doc');
|
|
226
237
|
});
|
|
227
238
|
|
|
239
|
+
it('embeds an explicitly registered theme selected by the document', () => {
|
|
240
|
+
const external = compileTheme({
|
|
241
|
+
id: 'tenant-brand',
|
|
242
|
+
name: 'External Tenant Brand',
|
|
243
|
+
seedColors: { primary: '#6633cc' },
|
|
244
|
+
});
|
|
245
|
+
const html = docToHtml(makeDoc({ themeId: external.id }), {
|
|
246
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
247
|
+
themeRegistry: createThemeRegistry([external]),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(html).toContain('External Tenant Brand');
|
|
251
|
+
expect(html).toContain('\\"themeId\\":\\"tenant-brand\\"');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('keeps a document-scoped theme ahead of the explicit registry', () => {
|
|
255
|
+
const inline = compileTheme({
|
|
256
|
+
id: 'tenant-brand',
|
|
257
|
+
name: 'Inline Tenant Brand',
|
|
258
|
+
seedColors: { primary: '#112233' },
|
|
259
|
+
});
|
|
260
|
+
const external = compileTheme({
|
|
261
|
+
id: 'tenant-brand',
|
|
262
|
+
name: 'External Tenant Brand',
|
|
263
|
+
seedColors: { primary: '#6633cc' },
|
|
264
|
+
});
|
|
265
|
+
const html = docToHtml(makeDoc({ themeId: inline.id, customThemes: [inline] }), {
|
|
266
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
267
|
+
themeRegistry: createThemeRegistry([external]),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(html).toContain('Inline Tenant Brand');
|
|
271
|
+
expect(html).not.toContain('External Tenant Brand');
|
|
272
|
+
});
|
|
273
|
+
|
|
228
274
|
it('calls SquisqPlayer.mount', () => {
|
|
229
275
|
const doc = makeDoc();
|
|
230
276
|
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
@@ -310,6 +356,22 @@ describe('docToHtmlZip', () => {
|
|
|
310
356
|
expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
|
|
311
357
|
});
|
|
312
358
|
|
|
359
|
+
it('embeds a caller-owned theme selected by the document', async () => {
|
|
360
|
+
const external = compileTheme({
|
|
361
|
+
id: 'tenant-zip-brand',
|
|
362
|
+
name: 'Tenant ZIP Brand',
|
|
363
|
+
seedColors: { primary: '#224466' },
|
|
364
|
+
});
|
|
365
|
+
const blob = await docToHtmlZip(makeDoc({ themeId: external.id }), {
|
|
366
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
367
|
+
themeRegistry: createThemeRegistry([external]),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
371
|
+
const html = await zip.file('index.html')!.async('text');
|
|
372
|
+
expect(html).toContain('Tenant ZIP Brand');
|
|
373
|
+
});
|
|
374
|
+
|
|
313
375
|
it('preserves the original image path inside the zip', async () => {
|
|
314
376
|
// Direct `<img src="folder/file.png">` references in the rendered HTML
|
|
315
377
|
// resolve only if the zip mirrors the doc's path layout — flattening
|