@bendyline/squisq-formats 2.0.0 → 2.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-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
- package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
- package/dist/chunk-7ARKUCQT.js.map +1 -0
- package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
- package/dist/chunk-DTDF6QDP.js.map +1 -0
- package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
- package/dist/chunk-EW54IRRS.js.map +1 -0
- package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
- package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
- package/dist/chunk-NNHKUXKA.js.map +1 -0
- package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
- package/dist/chunk-QFLDYKCR.js.map +1 -0
- package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
- package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
- package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
- package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
- package/dist/chunk-WQSHGBLN.js.map +1 -0
- package/dist/container/index.d.ts +10 -4
- package/dist/container/index.js +9 -4
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +2 -2
- package/dist/docx/index.js +5 -4
- package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
- package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
- package/dist/html/index.js +1 -1
- package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +15 -13
- package/dist/infer/index.d.ts +4 -4
- package/dist/infer/index.js +5 -5
- package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
- package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
- package/dist/ooxml/index.d.ts +4 -4
- package/dist/ooxml/index.js +4 -4
- package/dist/pptx/index.d.ts +5 -5
- package/dist/pptx/index.js +5 -5
- package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
- package/dist/registry/index.d.ts +20 -6
- package/dist/registry/index.js +6 -4
- package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
- package/dist/xlsx/index.d.ts +4 -4
- package/dist/xlsx/index.js +4 -4
- package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
- package/package.json +2 -2
- package/src/__tests__/convert.test.ts +143 -1
- package/src/__tests__/docxExport.test.ts +34 -0
- package/src/__tests__/docxImport.test.ts +121 -0
- package/src/__tests__/html.test.ts +4 -0
- package/src/__tests__/ooxml.test.ts +17 -0
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/container/index.ts +17 -3
- package/src/docx/export.ts +113 -5
- package/src/docx/import.ts +324 -69
- package/src/html/htmlTemplate.ts +2 -2
- package/src/index.ts +3 -0
- package/src/infer/index.ts +35 -4
- package/src/ooxml/namespaces.ts +21 -0
- package/src/ooxml/reader.ts +55 -14
- package/src/pptx/layouts.ts +68 -14
- package/src/registry/convert.ts +115 -31
- package/src/registry/defaultFormats.ts +5 -2
- package/src/registry/index.ts +3 -1
- package/src/registry/types.ts +18 -1
- package/src/shared/boundedZipArchive.ts +49 -8
- package/src/shared/zipEntryCount.ts +59 -5
- package/src/shared/zipLimits.ts +10 -0
- package/dist/chunk-2CSZJEAL.js.map +0 -1
- package/dist/chunk-2EFDWKC6.js.map +0 -1
- package/dist/chunk-2PRVLAU3.js.map +0 -1
- package/dist/chunk-4HJ6AK2Q.js.map +0 -1
- package/dist/chunk-H7T5DXUU.js.map +0 -1
- package/dist/chunk-LXYLOOST.js.map +0 -1
- package/dist/chunk-ZZ7D4DN6.js.map +0 -1
- /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
- /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
- /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
- /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
- /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
- /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
- /package/dist/{layouts-7OUUVAGU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
|
@@ -10,7 +10,7 @@ import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
|
10
10
|
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
11
11
|
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
12
12
|
import { createTransformStyleRegistry, resolveTransformStyle } from '@bendyline/squisq/transform';
|
|
13
|
-
import { convert, ConversionError, defaultRegistry } from '../registry/index';
|
|
13
|
+
import { convert, ConversionError, defaultRegistry, prepareConversion } from '../registry/index';
|
|
14
14
|
import { zipToContainer } from '../container/index';
|
|
15
15
|
import { markdownDocToPptx } from '../pptx/export';
|
|
16
16
|
import { buildThemedPptx } from './pptxInferFixtures';
|
|
@@ -57,6 +57,31 @@ describe('convert error paths', () => {
|
|
|
57
57
|
format: 'docx',
|
|
58
58
|
});
|
|
59
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
|
+
});
|
|
60
85
|
});
|
|
61
86
|
|
|
62
87
|
describe('format option threading', () => {
|
|
@@ -110,6 +135,16 @@ describe('format option threading', () => {
|
|
|
110
135
|
}),
|
|
111
136
|
).rejects.toMatchObject({ code: 'invalid-input', format: 'docx' });
|
|
112
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
|
+
});
|
|
113
148
|
});
|
|
114
149
|
|
|
115
150
|
// ── round trip: md → docx → md ──────────────────────────────────────
|
|
@@ -191,6 +226,113 @@ describe('byte sniffing', () => {
|
|
|
191
226
|
// ── transform threading ─────────────────────────────────────────────
|
|
192
227
|
|
|
193
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
|
+
|
|
194
336
|
it('applies a transform style before handing off to the exporter', async () => {
|
|
195
337
|
// A capture format records the NormalizedInput it receives, letting us
|
|
196
338
|
// observe that the transform ran without depending on any converter.
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
MarkdownEmphasis,
|
|
23
23
|
MarkdownInlineCode,
|
|
24
24
|
MarkdownLink,
|
|
25
|
+
MarkdownContainerDirective,
|
|
25
26
|
} from '@bendyline/squisq/markdown';
|
|
26
27
|
|
|
27
28
|
import { markdownDocToDocx, docToDocx } from '../docx/export';
|
|
@@ -81,6 +82,39 @@ describe('markdownDocToDocx', () => {
|
|
|
81
82
|
expect(body).toBeDefined();
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
it('exports DOCX story directives to header and footer parts', async () => {
|
|
86
|
+
const doc: MarkdownDocument = {
|
|
87
|
+
type: 'document',
|
|
88
|
+
children: [
|
|
89
|
+
{ type: 'paragraph', children: [{ type: 'text', value: 'Body' }] },
|
|
90
|
+
{
|
|
91
|
+
type: 'containerDirective',
|
|
92
|
+
name: 'docx-header',
|
|
93
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Header' }] }],
|
|
94
|
+
} satisfies MarkdownContainerDirective,
|
|
95
|
+
{
|
|
96
|
+
type: 'containerDirective',
|
|
97
|
+
name: 'docx-footer',
|
|
98
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Footer' }] }],
|
|
99
|
+
} satisfies MarkdownContainerDirective,
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const zip = await exportAndParse(doc);
|
|
104
|
+
const documentXml = await zip.file('word/document.xml')!.async('text');
|
|
105
|
+
const headerXml = await zip.file('word/header1.xml')!.async('text');
|
|
106
|
+
const footerXml = await zip.file('word/footer1.xml')!.async('text');
|
|
107
|
+
const relationships = await zip.file('word/_rels/document.xml.rels')!.async('text');
|
|
108
|
+
|
|
109
|
+
expect(documentXml).toContain('<w:headerReference');
|
|
110
|
+
expect(documentXml).toContain('<w:footerReference');
|
|
111
|
+
expect(documentXml).not.toContain('Header');
|
|
112
|
+
expect(headerXml).toContain('Header');
|
|
113
|
+
expect(footerXml).toContain('Footer');
|
|
114
|
+
expect(relationships).toContain('/relationships/header');
|
|
115
|
+
expect(relationships).toContain('/relationships/footer');
|
|
116
|
+
});
|
|
117
|
+
|
|
84
118
|
// ============================================
|
|
85
119
|
// Headings
|
|
86
120
|
// ============================================
|
|
@@ -14,9 +14,13 @@ import {
|
|
|
14
14
|
REL_STYLES,
|
|
15
15
|
REL_NUMBERING,
|
|
16
16
|
REL_HYPERLINK,
|
|
17
|
+
REL_HEADER,
|
|
18
|
+
REL_FOOTER,
|
|
17
19
|
CONTENT_TYPE_DOCX_DOCUMENT,
|
|
18
20
|
CONTENT_TYPE_DOCX_STYLES,
|
|
19
21
|
CONTENT_TYPE_DOCX_NUMBERING,
|
|
22
|
+
CONTENT_TYPE_DOCX_HEADER,
|
|
23
|
+
CONTENT_TYPE_DOCX_FOOTER,
|
|
20
24
|
} from '../ooxml/namespaces';
|
|
21
25
|
import { xmlDeclaration } from '../ooxml/xmlUtils';
|
|
22
26
|
import { docxToMarkdownDoc, docxToDoc } from '../docx/import';
|
|
@@ -31,6 +35,7 @@ import type {
|
|
|
31
35
|
MarkdownStrikethrough,
|
|
32
36
|
MarkdownLink,
|
|
33
37
|
MarkdownInlineNode,
|
|
38
|
+
MarkdownContainerDirective,
|
|
34
39
|
} from '@bendyline/squisq/markdown';
|
|
35
40
|
|
|
36
41
|
// ============================================
|
|
@@ -47,6 +52,7 @@ interface DocxFixtureOptions {
|
|
|
47
52
|
target: string;
|
|
48
53
|
targetMode?: 'External';
|
|
49
54
|
}>;
|
|
55
|
+
extraParts?: Array<{ path: string; content: string; contentType: string }>;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
async function buildTestDocx(options: DocxFixtureOptions): Promise<ArrayBuffer> {
|
|
@@ -81,6 +87,10 @@ async function buildTestDocx(options: DocxFixtureOptions): Promise<ArrayBuffer>
|
|
|
81
87
|
pkg.addPart('word/numbering.xml', options.numberingXml, CONTENT_TYPE_DOCX_NUMBERING);
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
for (const part of options.extraParts ?? []) {
|
|
91
|
+
pkg.addPart(part.path, part.content, part.contentType);
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
// Root relationship
|
|
85
95
|
pkg.addRelationship('', {
|
|
86
96
|
id: 'rId1',
|
|
@@ -164,6 +174,25 @@ describe('docxToMarkdownDoc', () => {
|
|
|
164
174
|
);
|
|
165
175
|
});
|
|
166
176
|
|
|
177
|
+
it('imports visible content inside controls, revisions, and text boxes once', async () => {
|
|
178
|
+
const data = await buildTestDocx({
|
|
179
|
+
bodyXml:
|
|
180
|
+
`<w:p>` +
|
|
181
|
+
`<w:r><w:t>Body </w:t></w:r>` +
|
|
182
|
+
`<w:sdt><w:sdtContent><w:r><w:t>controlled </w:t></w:r></w:sdtContent></w:sdt>` +
|
|
183
|
+
`<w:ins><w:r><w:t>inserted </w:t></w:r></w:ins>` +
|
|
184
|
+
`<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">` +
|
|
185
|
+
`<mc:Choice><w:drawing><w:txbxContent><w:p><w:r><w:t>text box</w:t></w:r></w:p></w:txbxContent></w:drawing></mc:Choice>` +
|
|
186
|
+
`<mc:Fallback><w:pict><w:txbxContent><w:p><w:r><w:t>fallback duplicate</w:t></w:r></w:p></w:txbxContent></w:pict></mc:Fallback>` +
|
|
187
|
+
`</mc:AlternateContent>` +
|
|
188
|
+
`</w:p>`,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const paragraph = docText(await docxToMarkdownDoc(data));
|
|
192
|
+
expect(paragraph).toContain('Body controlled inserted text box');
|
|
193
|
+
expect(paragraph).not.toContain('fallback duplicate');
|
|
194
|
+
});
|
|
195
|
+
|
|
167
196
|
it('skips empty paragraphs', async () => {
|
|
168
197
|
const data = await buildTestDocx({
|
|
169
198
|
bodyXml: `<w:p><w:pPr/></w:p><w:p><w:r><w:t>Text</w:t></w:r></w:p>`,
|
|
@@ -331,6 +360,27 @@ describe('docxToMarkdownDoc', () => {
|
|
|
331
360
|
expect(list.ordered).toBe(true);
|
|
332
361
|
});
|
|
333
362
|
|
|
363
|
+
it('retains nested list text after an empty parent numbering paragraph', async () => {
|
|
364
|
+
const numberingXml =
|
|
365
|
+
xmlDeclaration() +
|
|
366
|
+
`<w:numbering xmlns:w="${NS_WML}">` +
|
|
367
|
+
`<w:abstractNum w:abstractNumId="3">` +
|
|
368
|
+
`<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/></w:lvl>` +
|
|
369
|
+
`<w:lvl w:ilvl="1"><w:numFmt w:val="decimal"/></w:lvl>` +
|
|
370
|
+
`</w:abstractNum>` +
|
|
371
|
+
`<w:num w:numId="3"><w:abstractNumId w:val="3"/></w:num>` +
|
|
372
|
+
`</w:numbering>`;
|
|
373
|
+
const data = await buildTestDocx({
|
|
374
|
+
bodyXml:
|
|
375
|
+
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr></w:p>` +
|
|
376
|
+
`<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="3"/></w:numPr></w:pPr>` +
|
|
377
|
+
`<w:r><w:t>Nested content survives</w:t></w:r></w:p>`,
|
|
378
|
+
numberingXml,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(docText(await docxToMarkdownDoc(data))).toContain('Nested content survives');
|
|
382
|
+
});
|
|
383
|
+
|
|
334
384
|
// ============================================
|
|
335
385
|
// Tables
|
|
336
386
|
// ============================================
|
|
@@ -363,6 +413,57 @@ describe('docxToMarkdownDoc', () => {
|
|
|
363
413
|
expect((cell10.children[0] as MarkdownText).value).toBe('1');
|
|
364
414
|
});
|
|
365
415
|
|
|
416
|
+
it('flattens nested table contents without losing their text', async () => {
|
|
417
|
+
const data = await buildTestDocx({
|
|
418
|
+
bodyXml:
|
|
419
|
+
`<w:tbl><w:tr><w:tc>` +
|
|
420
|
+
`<w:p><w:r><w:t>Outer</w:t></w:r></w:p>` +
|
|
421
|
+
`<w:tbl><w:tr>` +
|
|
422
|
+
`<w:tc><w:p><w:r><w:t>Nested A</w:t></w:r></w:p></w:tc>` +
|
|
423
|
+
`<w:tc><w:p><w:r><w:t>Nested B</w:t></w:r></w:p></w:tc>` +
|
|
424
|
+
`</w:tr></w:tbl>` +
|
|
425
|
+
`</w:tc></w:tr></w:tbl>`,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
expect(docText(await docxToMarkdownDoc(data))).toContain('Outer Nested A Nested B');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('imports headers and footers as explicit DOCX story directives', async () => {
|
|
432
|
+
const headerXml =
|
|
433
|
+
xmlDeclaration() +
|
|
434
|
+
`<w:hdr xmlns:w="${NS_WML}"><w:p><w:r><w:t>Header text</w:t></w:r></w:p></w:hdr>`;
|
|
435
|
+
const footerXml =
|
|
436
|
+
xmlDeclaration() +
|
|
437
|
+
`<w:ftr xmlns:w="${NS_WML}"><w:p><w:r><w:t>Footer text</w:t></w:r></w:p></w:ftr>`;
|
|
438
|
+
const data = await buildTestDocx({
|
|
439
|
+
bodyXml:
|
|
440
|
+
`<w:p><w:r><w:t>Body text</w:t></w:r></w:p>` +
|
|
441
|
+
`<w:sectPr><w:headerReference w:type="default" r:id="rIdHeader"/>` +
|
|
442
|
+
`<w:footerReference w:type="default" r:id="rIdFooter"/></w:sectPr>`,
|
|
443
|
+
documentRels: [
|
|
444
|
+
{ id: 'rIdHeader', type: REL_HEADER, target: 'header1.xml' },
|
|
445
|
+
{ id: 'rIdFooter', type: REL_FOOTER, target: 'footer1.xml' },
|
|
446
|
+
],
|
|
447
|
+
extraParts: [
|
|
448
|
+
{ path: 'word/header1.xml', content: headerXml, contentType: CONTENT_TYPE_DOCX_HEADER },
|
|
449
|
+
{ path: 'word/footer1.xml', content: footerXml, contentType: CONTENT_TYPE_DOCX_FOOTER },
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const doc = await docxToMarkdownDoc(data);
|
|
454
|
+
const header = doc.children.find(
|
|
455
|
+
(node): node is MarkdownContainerDirective =>
|
|
456
|
+
node.type === 'containerDirective' && node.name === 'docx-header',
|
|
457
|
+
);
|
|
458
|
+
const footer = doc.children.find(
|
|
459
|
+
(node): node is MarkdownContainerDirective =>
|
|
460
|
+
node.type === 'containerDirective' && node.name === 'docx-footer',
|
|
461
|
+
);
|
|
462
|
+
expect(header?.attributes?.type).toBe('default');
|
|
463
|
+
expect(footer?.attributes?.type).toBe('default');
|
|
464
|
+
expect(docText(doc)).toContain('Body text Header text Footer text');
|
|
465
|
+
});
|
|
466
|
+
|
|
366
467
|
// ============================================
|
|
367
468
|
// Empty Document
|
|
368
469
|
// ============================================
|
|
@@ -387,8 +488,28 @@ describe('docxToMarkdownDoc', () => {
|
|
|
387
488
|
const para = doc.children[0] as MarkdownParagraph;
|
|
388
489
|
expect(para.children.some((c: MarkdownInlineNode) => c.type === 'break')).toBe(true);
|
|
389
490
|
});
|
|
491
|
+
|
|
492
|
+
it('retains tabs as visible word boundaries', async () => {
|
|
493
|
+
const data = await buildTestDocx({
|
|
494
|
+
bodyXml: `<w:p><w:r><w:t>Before</w:t><w:tab/><w:t>After</w:t></w:r></w:p>`,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(docText(await docxToMarkdownDoc(data))).toBe('Before After');
|
|
498
|
+
});
|
|
390
499
|
});
|
|
391
500
|
|
|
501
|
+
function docText(doc: Awaited<ReturnType<typeof docxToMarkdownDoc>>): string {
|
|
502
|
+
const text: string[] = [];
|
|
503
|
+
const visit = (node: unknown): void => {
|
|
504
|
+
if (!node || typeof node !== 'object') return;
|
|
505
|
+
const value = node as { type?: string; value?: string; children?: unknown[] };
|
|
506
|
+
if ((value.type === 'text' || value.type === 'code') && value.value) text.push(value.value);
|
|
507
|
+
value.children?.forEach(visit);
|
|
508
|
+
};
|
|
509
|
+
visit(doc);
|
|
510
|
+
return text.join(' ').replace(/\s+/g, ' ').trim();
|
|
511
|
+
}
|
|
512
|
+
|
|
392
513
|
// ============================================
|
|
393
514
|
// docxToDoc convenience wrapper
|
|
394
515
|
// ============================================
|
|
@@ -300,6 +300,10 @@ describe('docToHtml', () => {
|
|
|
300
300
|
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT, mode: 'static' });
|
|
301
301
|
|
|
302
302
|
expect(html).toContain('"static"');
|
|
303
|
+
// The page rendition owns its own background/scrolling — the host shim
|
|
304
|
+
// must not hardcode a white background that fights themed pages.
|
|
305
|
+
expect(html).toContain('#squisq-root{display:block}');
|
|
306
|
+
expect(html).not.toContain('background:#fff');
|
|
303
307
|
});
|
|
304
308
|
|
|
305
309
|
it('inlines images as base64 data URIs', () => {
|
|
@@ -126,6 +126,23 @@ describe('createPackage / openPackage round-trip', () => {
|
|
|
126
126
|
expect(doc?.getElementsByTagName('value')[0]?.textContent).toBe('node');
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it('ignores an incomplete ambient DOMParser when running in Node', async () => {
|
|
130
|
+
const pkg = createPackage();
|
|
131
|
+
pkg.addPart('word/document.xml', '<doc><value>node</value></doc>', 'application/xml');
|
|
132
|
+
const buffer = await pkg.toArrayBuffer();
|
|
133
|
+
|
|
134
|
+
class IncompleteDomParser {
|
|
135
|
+
parseFromString(): never {
|
|
136
|
+
throw new Error('ambient parser must not be used in Node');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
vi.stubGlobal('DOMParser', IncompleteDomParser);
|
|
141
|
+
const opened = await openPackage(buffer);
|
|
142
|
+
const doc = await getPartXml(opened, 'word/document.xml');
|
|
143
|
+
expect(doc?.getElementsByTagName('value')[0]?.textContent).toBe('node');
|
|
144
|
+
});
|
|
145
|
+
|
|
129
146
|
it('applies archive resource limits before parsing OOXML parts', async () => {
|
|
130
147
|
const pkg = createPackage();
|
|
131
148
|
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { inferThemeFromFile } from '../infer/index';
|
|
3
|
+
import { getPartXml, openPackage } from '../ooxml/reader';
|
|
4
|
+
import { inspectPptxLayouts } from '../pptx/layouts';
|
|
5
|
+
import { prepareZipInput } from '../shared/zipEntryCount';
|
|
6
|
+
import { buildThemedPptx } from './pptxInferFixtures';
|
|
7
|
+
|
|
8
|
+
function blockingBlob(onRead: () => void): Blob {
|
|
9
|
+
const blob = {
|
|
10
|
+
size: 128,
|
|
11
|
+
slice: () => blob,
|
|
12
|
+
arrayBuffer: () => {
|
|
13
|
+
onRead();
|
|
14
|
+
return new Promise<ArrayBuffer>(() => undefined);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
return blob as unknown as Blob;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('OOXML cancellation', () => {
|
|
21
|
+
it('preserves the exact reason while theme inference reads Blob input', async () => {
|
|
22
|
+
let markStarted: (() => void) | undefined;
|
|
23
|
+
const started = new Promise<void>((resolve) => {
|
|
24
|
+
markStarted = resolve;
|
|
25
|
+
});
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const reason = new Error('stop inference');
|
|
28
|
+
const pending = inferThemeFromFile(
|
|
29
|
+
blockingBlob(() => markStarted?.()),
|
|
30
|
+
{
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await started;
|
|
36
|
+
controller.abort(reason);
|
|
37
|
+
|
|
38
|
+
await expect(pending).rejects.toBe(reason);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('preserves the exact reason while layout inspection opens Blob input', async () => {
|
|
42
|
+
let markStarted: (() => void) | undefined;
|
|
43
|
+
const started = new Promise<void>((resolve) => {
|
|
44
|
+
markStarted = resolve;
|
|
45
|
+
});
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const reason = new Error('stop layout inspection');
|
|
48
|
+
const pending = inspectPptxLayouts(
|
|
49
|
+
blockingBlob(() => markStarted?.()),
|
|
50
|
+
{
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await started;
|
|
56
|
+
controller.abort(reason);
|
|
57
|
+
|
|
58
|
+
await expect(pending).rejects.toBe(reason);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('propagates package cancellation through later OOXML part traversal', async () => {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const pkg = await openPackage(await buildThemedPptx(), { signal: controller.signal });
|
|
64
|
+
const reason = new Error('stop OOXML traversal');
|
|
65
|
+
controller.abort(reason);
|
|
66
|
+
|
|
67
|
+
await expect(getPartXml(pkg, 'ppt/presentation.xml')).rejects.toBe(reason);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('handles a synchronously re-entrant FileReader abort exactly once', async () => {
|
|
71
|
+
const previous = globalThis.FileReader;
|
|
72
|
+
let abortCalls = 0;
|
|
73
|
+
class ReentrantFileReader {
|
|
74
|
+
result: ArrayBuffer | null = null;
|
|
75
|
+
error: DOMException | null = null;
|
|
76
|
+
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
77
|
+
onerror: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
78
|
+
onabort: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
79
|
+
readAsArrayBuffer(): void {
|
|
80
|
+
// Remain pending until the caller aborts.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
abort(): void {
|
|
84
|
+
abortCalls += 1;
|
|
85
|
+
this.onabort?.({} as ProgressEvent<FileReader>);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperty(globalThis, 'FileReader', {
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: ReentrantFileReader,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const blobLike = { size: 1, slice: () => blobLike } as unknown as Blob;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const reason = new Error('stop FileReader');
|
|
97
|
+
try {
|
|
98
|
+
const pending = prepareZipInput(blobLike, controller.signal);
|
|
99
|
+
controller.abort(reason);
|
|
100
|
+
await expect(pending).rejects.toBe(reason);
|
|
101
|
+
expect(abortCalls).toBe(1);
|
|
102
|
+
} finally {
|
|
103
|
+
if (previous === undefined) Reflect.deleteProperty(globalThis, 'FileReader');
|
|
104
|
+
else {
|
|
105
|
+
Object.defineProperty(globalThis, 'FileReader', {
|
|
106
|
+
configurable: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
value: previous,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/container/index.ts
CHANGED
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
} from '../shared/zipSafety.js';
|
|
25
25
|
|
|
26
26
|
export type ZipToContainerOptions = ZipSafetyLimits;
|
|
27
|
+
export interface ContainerToZipOptions {
|
|
28
|
+
/** STORE avoids producing archives that violate strict import compression-ratio policies. */
|
|
29
|
+
compression?: 'STORE' | 'DEFLATE';
|
|
30
|
+
/** JSZip DEFLATE level; ignored for STORE. */
|
|
31
|
+
compressionLevel?: number;
|
|
32
|
+
}
|
|
27
33
|
export { ZipSafetyError };
|
|
28
34
|
export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
29
35
|
|
|
@@ -36,7 +42,10 @@ export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
|
36
42
|
* @param container — The container to serialize
|
|
37
43
|
* @returns A Blob containing the ZIP archive
|
|
38
44
|
*/
|
|
39
|
-
export async function containerToZip(
|
|
45
|
+
export async function containerToZip(
|
|
46
|
+
container: ContentContainer,
|
|
47
|
+
options: ContainerToZipOptions = {},
|
|
48
|
+
): Promise<Blob> {
|
|
40
49
|
const zip = new JSZip();
|
|
41
50
|
const entries = await container.listFiles();
|
|
42
51
|
|
|
@@ -48,10 +57,15 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
|
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
const compression = options.compression ?? 'DEFLATE';
|
|
61
|
+
const compressionLevel = options.compressionLevel ?? 6;
|
|
62
|
+
if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 1 || compressionLevel > 9) {
|
|
63
|
+
throw new Error('ZIP compression level must be an integer between 1 and 9');
|
|
64
|
+
}
|
|
51
65
|
return zip.generateAsync({
|
|
52
66
|
type: 'blob',
|
|
53
|
-
compression
|
|
54
|
-
compressionOptions: { level:
|
|
67
|
+
compression,
|
|
68
|
+
...(compression === 'DEFLATE' ? { compressionOptions: { level: compressionLevel } } : {}),
|
|
55
69
|
});
|
|
56
70
|
}
|
|
57
71
|
|