@bendyline/squisq-formats 0.1.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/dist/chunk-532L4D5D.js +21 -0
- package/dist/chunk-532L4D5D.js.map +1 -0
- package/dist/chunk-67KIJHV2.js +21 -0
- package/dist/chunk-67KIJHV2.js.map +1 -0
- package/dist/chunk-KAK4V57E.js +387 -0
- package/dist/chunk-KAK4V57E.js.map +1 -0
- package/dist/chunk-MQHCXI56.js +1035 -0
- package/dist/chunk-MQHCXI56.js.map +1 -0
- package/dist/chunk-TBPD5PCU.js +1136 -0
- package/dist/chunk-TBPD5PCU.js.map +1 -0
- package/dist/chunk-ULLIPBEJ.js +271 -0
- package/dist/chunk-ULLIPBEJ.js.map +1 -0
- package/dist/docx/index.d.ts +108 -0
- package/dist/docx/index.js +14 -0
- package/dist/docx/index.js.map +1 -0
- package/dist/html/index.d.ts +166 -0
- package/dist/html/index.js +17 -0
- package/dist/html/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/ooxml/index.d.ts +351 -0
- package/dist/ooxml/index.js +99 -0
- package/dist/ooxml/index.js.map +1 -0
- package/dist/pdf/index.d.ts +130 -0
- package/dist/pdf/index.js +15 -0
- package/dist/pdf/index.js.map +1 -0
- package/dist/pptx/index.d.ts +58 -0
- package/dist/pptx/index.js +13 -0
- package/dist/pptx/index.js.map +1 -0
- package/dist/xlsx/index.d.ts +58 -0
- package/dist/xlsx/index.js +13 -0
- package/dist/xlsx/index.js.map +1 -0
- package/package.json +85 -0
- package/src/__tests__/docxExport.test.ts +457 -0
- package/src/__tests__/docxImport.test.ts +410 -0
- package/src/__tests__/html.test.ts +295 -0
- package/src/__tests__/ooxml.test.ts +197 -0
- package/src/__tests__/pdfExport.test.ts +322 -0
- package/src/__tests__/pdfImport.test.ts +290 -0
- package/src/__tests__/roundTrip.test.ts +201 -0
- package/src/docx/export.ts +978 -0
- package/src/docx/import.ts +909 -0
- package/src/docx/index.ts +26 -0
- package/src/docx/styles.ts +145 -0
- package/src/html/htmlTemplate.ts +307 -0
- package/src/html/imageUtils.ts +66 -0
- package/src/html/index.ts +168 -0
- package/src/index.ts +51 -0
- package/src/ooxml/index.ts +87 -0
- package/src/ooxml/namespaces.ts +160 -0
- package/src/ooxml/reader.ts +218 -0
- package/src/ooxml/types.ts +104 -0
- package/src/ooxml/writer.ts +321 -0
- package/src/ooxml/xmlUtils.ts +123 -0
- package/src/pdf/export.ts +1029 -0
- package/src/pdf/import.ts +835 -0
- package/src/pdf/index.ts +29 -0
- package/src/pdf/styles.ts +180 -0
- package/src/pptx/index.ts +78 -0
- package/src/xlsx/index.ts +78 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HTML export: docToHtml, docToHtmlZip, image utilities.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that generated HTML contains the expected structure,
|
|
5
|
+
* embedded script, and image handling. ZIP exports are also validated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
|
|
11
|
+
import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
|
|
12
|
+
import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
|
|
13
|
+
|
|
14
|
+
// ============================================
|
|
15
|
+
// Helpers
|
|
16
|
+
// ============================================
|
|
17
|
+
|
|
18
|
+
const MOCK_PLAYER_SCRIPT = 'var SquisqPlayer={mount:function(){}};';
|
|
19
|
+
|
|
20
|
+
/** Helper to read a Blob as Uint8Array (works in jsdom where blob.arrayBuffer() may not exist) */
|
|
21
|
+
async function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const reader = new FileReader();
|
|
24
|
+
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
|
|
25
|
+
reader.onerror = () => reject(reader.error);
|
|
26
|
+
reader.readAsArrayBuffer(blob);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDoc(overrides: Partial<Doc> = {}): Doc {
|
|
31
|
+
return {
|
|
32
|
+
articleId: 'test-doc',
|
|
33
|
+
duration: 10,
|
|
34
|
+
blocks: [],
|
|
35
|
+
audio: {
|
|
36
|
+
segments: [{ src: 'intro.mp3', name: 'intro', duration: 10, startTime: 0 }],
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeImageBlock(imageSrc: string): Block {
|
|
43
|
+
const layer: ImageLayer = {
|
|
44
|
+
id: 'img-1',
|
|
45
|
+
type: 'image',
|
|
46
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
47
|
+
content: { src: imageSrc, alt: 'test image' },
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
id: 'block-1',
|
|
51
|
+
startTime: 0,
|
|
52
|
+
duration: 5,
|
|
53
|
+
audioSegment: 0,
|
|
54
|
+
layers: [layer],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeImageBuffer(): ArrayBuffer {
|
|
59
|
+
// Minimal 1x1 PNG
|
|
60
|
+
const bytes = new Uint8Array([
|
|
61
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
62
|
+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
63
|
+
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
64
|
+
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
|
65
|
+
0x44, 0xae, 0x42, 0x60, 0x82,
|
|
66
|
+
]);
|
|
67
|
+
return bytes.buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================
|
|
71
|
+
// Image Utilities
|
|
72
|
+
// ============================================
|
|
73
|
+
|
|
74
|
+
describe('inferMimeType', () => {
|
|
75
|
+
it('returns correct MIME types for common extensions', () => {
|
|
76
|
+
expect(inferMimeType('photo.jpg')).toBe('image/jpeg');
|
|
77
|
+
expect(inferMimeType('photo.jpeg')).toBe('image/jpeg');
|
|
78
|
+
expect(inferMimeType('logo.png')).toBe('image/png');
|
|
79
|
+
expect(inferMimeType('anim.gif')).toBe('image/gif');
|
|
80
|
+
expect(inferMimeType('hero.webp')).toBe('image/webp');
|
|
81
|
+
expect(inferMimeType('icon.svg')).toBe('image/svg+xml');
|
|
82
|
+
expect(inferMimeType('track.mp3')).toBe('audio/mpeg');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns octet-stream for unknown extensions', () => {
|
|
86
|
+
expect(inferMimeType('data.xyz')).toBe('application/octet-stream');
|
|
87
|
+
expect(inferMimeType('noext')).toBe('application/octet-stream');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('arrayBufferToBase64DataUrl', () => {
|
|
92
|
+
it('produces a valid data URI', () => {
|
|
93
|
+
const buffer = new TextEncoder().encode('hello').buffer;
|
|
94
|
+
const result = arrayBufferToBase64DataUrl(buffer, 'text/plain');
|
|
95
|
+
expect(result).toMatch(/^data:text\/plain;base64,/);
|
|
96
|
+
expect(result).toBe('data:text/plain;base64,aGVsbG8=');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('extractFilename', () => {
|
|
101
|
+
it('extracts filename from path', () => {
|
|
102
|
+
expect(extractFilename('images/hero.jpg')).toBe('hero.jpg');
|
|
103
|
+
expect(extractFilename('hero.jpg')).toBe('hero.jpg');
|
|
104
|
+
expect(extractFilename('a/b/c/deep.png')).toBe('deep.png');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('strips query and hash', () => {
|
|
108
|
+
expect(extractFilename('photo.jpg?v=2')).toBe('photo.jpg');
|
|
109
|
+
expect(extractFilename('photo.jpg#anchor')).toBe('photo.jpg');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ============================================
|
|
114
|
+
// Image Path Collection
|
|
115
|
+
// ============================================
|
|
116
|
+
|
|
117
|
+
describe('collectImagePaths', () => {
|
|
118
|
+
it('collects image layer src from blocks', () => {
|
|
119
|
+
const doc = makeDoc({ blocks: [makeImageBlock('hero.jpg')] });
|
|
120
|
+
const paths = collectImagePaths(doc);
|
|
121
|
+
expect(paths.has('hero.jpg')).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('ignores absolute URLs', () => {
|
|
125
|
+
const doc = makeDoc({ blocks: [makeImageBlock('https://example.com/photo.jpg')] });
|
|
126
|
+
const paths = collectImagePaths(doc);
|
|
127
|
+
expect(paths.size).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('ignores data URIs', () => {
|
|
131
|
+
const doc = makeDoc({ blocks: [makeImageBlock('data:image/png;base64,abc')] });
|
|
132
|
+
const paths = collectImagePaths(doc);
|
|
133
|
+
expect(paths.size).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('collects startBlock heroSrc', () => {
|
|
137
|
+
const doc = makeDoc({ startBlock: { heroSrc: 'cover.jpg', title: 'Test' } });
|
|
138
|
+
const paths = collectImagePaths(doc);
|
|
139
|
+
expect(paths.has('cover.jpg')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ============================================
|
|
144
|
+
// Single HTML Export
|
|
145
|
+
// ============================================
|
|
146
|
+
|
|
147
|
+
describe('docToHtml', () => {
|
|
148
|
+
it('produces a complete HTML document', () => {
|
|
149
|
+
const doc = makeDoc();
|
|
150
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
151
|
+
|
|
152
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
153
|
+
expect(html).toContain('<html');
|
|
154
|
+
expect(html).toContain('</html>');
|
|
155
|
+
expect(html).toContain('<meta charset="UTF-8">');
|
|
156
|
+
expect(html).toContain('<div id="squisq-root">');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('embeds the player script inline', () => {
|
|
160
|
+
const doc = makeDoc();
|
|
161
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
162
|
+
|
|
163
|
+
expect(html).toContain(MOCK_PLAYER_SCRIPT);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('embeds the doc JSON', () => {
|
|
167
|
+
const doc = makeDoc();
|
|
168
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
169
|
+
|
|
170
|
+
expect(html).toContain('test-doc');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('calls SquisqPlayer.mount', () => {
|
|
174
|
+
const doc = makeDoc();
|
|
175
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
176
|
+
|
|
177
|
+
expect(html).toContain('SquisqPlayer.mount');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('sets the page title', () => {
|
|
181
|
+
const doc = makeDoc();
|
|
182
|
+
const html = docToHtml(doc, {
|
|
183
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
184
|
+
title: 'My Amazing Doc',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(html).toContain('<title>My Amazing Doc</title>');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('uses slideshow mode by default', () => {
|
|
191
|
+
const doc = makeDoc();
|
|
192
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
193
|
+
|
|
194
|
+
expect(html).toContain('"slideshow"');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('supports static mode', () => {
|
|
198
|
+
const doc = makeDoc();
|
|
199
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, mode: 'static' });
|
|
200
|
+
|
|
201
|
+
expect(html).toContain('"static"');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('inlines images as base64 data URIs', () => {
|
|
205
|
+
const doc = makeDoc({ blocks: [makeImageBlock('hero.png')] });
|
|
206
|
+
const images = new Map([['hero.png', makeImageBuffer()]]);
|
|
207
|
+
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
|
|
208
|
+
|
|
209
|
+
expect(html).toContain('data:image/png;base64,');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('escapes script-breaking content in title', () => {
|
|
213
|
+
const doc = makeDoc();
|
|
214
|
+
const html = docToHtml(doc, {
|
|
215
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
216
|
+
title: '<script>alert("xss")</script>',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Title should be escaped
|
|
220
|
+
expect(html).not.toContain('<script>alert');
|
|
221
|
+
expect(html).toContain('<script>');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ============================================
|
|
226
|
+
// ZIP Archive Export
|
|
227
|
+
// ============================================
|
|
228
|
+
|
|
229
|
+
describe('docToHtmlZip', () => {
|
|
230
|
+
it('produces a valid ZIP blob', async () => {
|
|
231
|
+
const doc = makeDoc();
|
|
232
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
233
|
+
|
|
234
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
235
|
+
expect(blob.size).toBeGreaterThan(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('contains index.html and squisq-player.js', async () => {
|
|
239
|
+
const doc = makeDoc();
|
|
240
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
241
|
+
|
|
242
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
243
|
+
expect(zip.file('index.html')).not.toBeNull();
|
|
244
|
+
expect(zip.file('squisq-player.js')).not.toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('references player script via src attribute', async () => {
|
|
248
|
+
const doc = makeDoc();
|
|
249
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
250
|
+
|
|
251
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
252
|
+
const html = await zip.file('index.html')!.async('text');
|
|
253
|
+
expect(html).toContain('src="squisq-player.js"');
|
|
254
|
+
// Should NOT contain the inline script
|
|
255
|
+
expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('includes images in images/ folder', async () => {
|
|
259
|
+
const doc = makeDoc({ blocks: [makeImageBlock('hero.png')] });
|
|
260
|
+
const images = new Map([['hero.png', makeImageBuffer()]]);
|
|
261
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, images });
|
|
262
|
+
|
|
263
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
264
|
+
expect(zip.file('images/hero.png')).not.toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('includes audio in audio/ folder when provided', async () => {
|
|
268
|
+
const audioData = new Uint8Array([0x49, 0x44, 0x33, 0x00]).buffer;
|
|
269
|
+
const doc = makeDoc();
|
|
270
|
+
const audio = new Map([['intro.mp3', audioData]]);
|
|
271
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT, audio });
|
|
272
|
+
|
|
273
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
274
|
+
expect(zip.file('audio/intro.mp3')).not.toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('HTML references doc JSON inline', async () => {
|
|
278
|
+
const doc = makeDoc();
|
|
279
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
280
|
+
|
|
281
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
282
|
+
const html = await zip.file('index.html')!.async('text');
|
|
283
|
+
expect(html).toContain('test-doc');
|
|
284
|
+
expect(html).toContain('SquisqPlayer.mount');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('player.js contains the provided script', async () => {
|
|
288
|
+
const doc = makeDoc();
|
|
289
|
+
const blob = await docToHtmlZip(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
290
|
+
|
|
291
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
292
|
+
const js = await zip.file('squisq-player.js')!.async('text');
|
|
293
|
+
expect(js).toBe(MOCK_PLAYER_SCRIPT);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared OOXML layer: xmlUtils, writer, reader round-trips.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
escapeXml,
|
|
8
|
+
xmlElement,
|
|
9
|
+
selfClosingElement,
|
|
10
|
+
textElement,
|
|
11
|
+
attrString,
|
|
12
|
+
xmlDeclaration,
|
|
13
|
+
} from '../ooxml/xmlUtils';
|
|
14
|
+
import { createPackage } from '../ooxml/writer';
|
|
15
|
+
import { openPackage, getPartXml, getCoreProperties, getPartRelationships } from '../ooxml/reader';
|
|
16
|
+
import { REL_OFFICE_DOCUMENT } from '../ooxml/namespaces';
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// XML Utilities
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
describe('escapeXml', () => {
|
|
23
|
+
it('escapes all five XML entities', () => {
|
|
24
|
+
expect(escapeXml('a & b < c > d " e \' f')).toBe('a & b < c > d " e ' f');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty string unchanged', () => {
|
|
28
|
+
expect(escapeXml('')).toBe('');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns plain text unchanged', () => {
|
|
32
|
+
expect(escapeXml('hello world')).toBe('hello world');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('attrString', () => {
|
|
37
|
+
it('builds attribute string from object', () => {
|
|
38
|
+
const result = attrString({ 'w:val': 'Heading1' });
|
|
39
|
+
expect(result).toBe(' w:val="Heading1"');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('omits undefined values', () => {
|
|
43
|
+
const result = attrString({ 'w:val': 'test', 'w:other': undefined });
|
|
44
|
+
expect(result).toBe(' w:val="test"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns empty string for empty attrs', () => {
|
|
48
|
+
expect(attrString({})).toBe('');
|
|
49
|
+
expect(attrString(undefined)).toBe('');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('selfClosingElement', () => {
|
|
54
|
+
it('builds self-closing tag', () => {
|
|
55
|
+
expect(selfClosingElement('w:b')).toBe('<w:b/>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('includes attributes', () => {
|
|
59
|
+
expect(selfClosingElement('w:pStyle', { 'w:val': 'Heading1' })).toBe(
|
|
60
|
+
'<w:pStyle w:val="Heading1"/>',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('xmlElement', () => {
|
|
66
|
+
it('builds element with children', () => {
|
|
67
|
+
expect(xmlElement('w:p', {}, 'content')).toBe('<w:p>content</w:p>');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('self-closes when no children', () => {
|
|
71
|
+
expect(xmlElement('w:p', {})).toBe('<w:p/>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('concatenates multiple children', () => {
|
|
75
|
+
expect(xmlElement('w:p', {}, 'a', 'b')).toBe('<w:p>ab</w:p>');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('textElement', () => {
|
|
80
|
+
it('escapes text content', () => {
|
|
81
|
+
expect(textElement('w:t', {}, 'a & b')).toBe('<w:t>a & b</w:t>');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('self-closes when no text', () => {
|
|
85
|
+
expect(textElement('w:t', {})).toBe('<w:t/>');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('xmlDeclaration', () => {
|
|
90
|
+
it('returns standard XML declaration', () => {
|
|
91
|
+
expect(xmlDeclaration()).toBe('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ============================================
|
|
96
|
+
// Package Writer + Reader Round-Trip
|
|
97
|
+
// ============================================
|
|
98
|
+
|
|
99
|
+
describe('createPackage / openPackage round-trip', () => {
|
|
100
|
+
it('creates a valid ZIP with content types and parts', async () => {
|
|
101
|
+
const pkg = createPackage();
|
|
102
|
+
pkg.addPart(
|
|
103
|
+
'word/document.xml',
|
|
104
|
+
'<w:document><w:body/></w:document>',
|
|
105
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml',
|
|
106
|
+
);
|
|
107
|
+
pkg.addRelationship('', {
|
|
108
|
+
id: 'rId1',
|
|
109
|
+
type: REL_OFFICE_DOCUMENT,
|
|
110
|
+
target: 'word/document.xml',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const arrayBuffer = await pkg.toArrayBuffer();
|
|
114
|
+
expect(arrayBuffer).toBeInstanceOf(ArrayBuffer);
|
|
115
|
+
expect(arrayBuffer.byteLength).toBeGreaterThan(0);
|
|
116
|
+
|
|
117
|
+
// Read it back
|
|
118
|
+
const opened = await openPackage(arrayBuffer);
|
|
119
|
+
expect(opened.zip).toBeDefined();
|
|
120
|
+
expect(opened.contentTypes.overrides.has('word/document.xml')).toBe(true);
|
|
121
|
+
expect(opened.rootRelationships.length).toBeGreaterThanOrEqual(1);
|
|
122
|
+
expect(opened.rootRelationships[0].type).toBe(REL_OFFICE_DOCUMENT);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('round-trips XML part content', async () => {
|
|
126
|
+
const pkg = createPackage();
|
|
127
|
+
const testXml =
|
|
128
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><root><child attr="value">text</child></root>';
|
|
129
|
+
pkg.addPart('test/data.xml', testXml, 'application/xml');
|
|
130
|
+
|
|
131
|
+
const buffer = await pkg.toArrayBuffer();
|
|
132
|
+
const opened = await openPackage(buffer);
|
|
133
|
+
const doc = await getPartXml(opened, 'test/data.xml');
|
|
134
|
+
|
|
135
|
+
expect(doc).not.toBeNull();
|
|
136
|
+
const child = doc!.getElementsByTagName('child')[0];
|
|
137
|
+
expect(child.getAttribute('attr')).toBe('value');
|
|
138
|
+
expect(child.textContent).toBe('text');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('round-trips core properties', async () => {
|
|
142
|
+
const pkg = createPackage();
|
|
143
|
+
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
144
|
+
pkg.setCoreProperties({
|
|
145
|
+
title: 'Test Title',
|
|
146
|
+
creator: 'Test Author',
|
|
147
|
+
description: 'Test description with <special> & chars',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const buffer = await pkg.toArrayBuffer();
|
|
151
|
+
const opened = await openPackage(buffer);
|
|
152
|
+
const props = await getCoreProperties(opened);
|
|
153
|
+
|
|
154
|
+
expect(props.title).toBe('Test Title');
|
|
155
|
+
expect(props.creator).toBe('Test Author');
|
|
156
|
+
expect(props.description).toBe('Test description with <special> & chars');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('round-trips part relationships', async () => {
|
|
160
|
+
const pkg = createPackage();
|
|
161
|
+
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
162
|
+
pkg.addRelationship('word/document.xml', {
|
|
163
|
+
id: 'rId1',
|
|
164
|
+
type: 'http://example.com/styles',
|
|
165
|
+
target: 'styles.xml',
|
|
166
|
+
});
|
|
167
|
+
pkg.addRelationship('word/document.xml', {
|
|
168
|
+
id: 'rId2',
|
|
169
|
+
type: 'http://example.com/hyperlink',
|
|
170
|
+
target: 'https://example.com',
|
|
171
|
+
targetMode: 'External',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const buffer = await pkg.toArrayBuffer();
|
|
175
|
+
const opened = await openPackage(buffer);
|
|
176
|
+
const rels = await getPartRelationships(opened, 'word/document.xml');
|
|
177
|
+
|
|
178
|
+
expect(rels.length).toBe(2);
|
|
179
|
+
expect(rels[0].id).toBe('rId1');
|
|
180
|
+
expect(rels[0].target).toBe('styles.xml');
|
|
181
|
+
expect(rels[1].id).toBe('rId2');
|
|
182
|
+
expect(rels[1].target).toBe('https://example.com');
|
|
183
|
+
expect(rels[1].targetMode).toBe('External');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('handles binary parts', async () => {
|
|
187
|
+
const pkg = createPackage();
|
|
188
|
+
const testData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
|
189
|
+
pkg.addBinaryPart('word/media/image1.png', testData, 'image/png');
|
|
190
|
+
|
|
191
|
+
const buffer = await pkg.toArrayBuffer();
|
|
192
|
+
const opened = await openPackage(buffer);
|
|
193
|
+
|
|
194
|
+
// Binary part should appear in content types via extension default
|
|
195
|
+
expect(opened.contentTypes.defaults.has('png')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|