@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.
Files changed (82) hide show
  1. package/dist/{chunk-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
  2. package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
  3. package/dist/chunk-7ARKUCQT.js.map +1 -0
  4. package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
  5. package/dist/chunk-DTDF6QDP.js.map +1 -0
  6. package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
  7. package/dist/chunk-EW54IRRS.js.map +1 -0
  8. package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
  9. package/dist/chunk-FE6OJV6O.js.map +1 -0
  10. package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
  11. package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
  12. package/dist/chunk-NNHKUXKA.js.map +1 -0
  13. package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
  14. package/dist/chunk-QFLDYKCR.js.map +1 -0
  15. package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
  16. package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
  17. package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
  18. package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
  19. package/dist/chunk-WQSHGBLN.js.map +1 -0
  20. package/dist/container/index.d.ts +10 -4
  21. package/dist/container/index.js +9 -4
  22. package/dist/container/index.js.map +1 -1
  23. package/dist/docx/index.d.ts +2 -2
  24. package/dist/docx/index.js +5 -4
  25. package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
  26. package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
  27. package/dist/html/index.js +1 -1
  28. package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
  29. package/dist/index.d.ts +8 -7
  30. package/dist/index.js +15 -13
  31. package/dist/infer/index.d.ts +4 -4
  32. package/dist/infer/index.js +5 -5
  33. package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
  34. package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
  35. package/dist/ooxml/index.d.ts +4 -4
  36. package/dist/ooxml/index.js +4 -4
  37. package/dist/pptx/index.d.ts +5 -5
  38. package/dist/pptx/index.js +5 -5
  39. package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
  40. package/dist/registry/index.d.ts +20 -6
  41. package/dist/registry/index.js +6 -4
  42. package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
  43. package/dist/xlsx/index.d.ts +4 -4
  44. package/dist/xlsx/index.js +4 -4
  45. package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/convert.test.ts +143 -1
  48. package/src/__tests__/docxExport.test.ts +34 -0
  49. package/src/__tests__/docxImport.test.ts +121 -0
  50. package/src/__tests__/html.test.ts +4 -0
  51. package/src/__tests__/ooxml.test.ts +17 -0
  52. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  53. package/src/container/index.ts +17 -3
  54. package/src/docx/export.ts +113 -5
  55. package/src/docx/import.ts +324 -69
  56. package/src/html/htmlTemplate.ts +2 -2
  57. package/src/index.ts +3 -0
  58. package/src/infer/index.ts +35 -4
  59. package/src/ooxml/namespaces.ts +21 -0
  60. package/src/ooxml/reader.ts +55 -14
  61. package/src/pptx/layouts.ts +68 -14
  62. package/src/registry/convert.ts +115 -31
  63. package/src/registry/defaultFormats.ts +5 -2
  64. package/src/registry/index.ts +3 -1
  65. package/src/registry/types.ts +18 -1
  66. package/src/shared/boundedZipArchive.ts +49 -8
  67. package/src/shared/zipEntryCount.ts +59 -5
  68. package/src/shared/zipLimits.ts +10 -0
  69. package/dist/chunk-2CSZJEAL.js.map +0 -1
  70. package/dist/chunk-2EFDWKC6.js.map +0 -1
  71. package/dist/chunk-2PRVLAU3.js.map +0 -1
  72. package/dist/chunk-4HJ6AK2Q.js.map +0 -1
  73. package/dist/chunk-H7T5DXUU.js.map +0 -1
  74. package/dist/chunk-LXYLOOST.js.map +0 -1
  75. package/dist/chunk-ZZ7D4DN6.js.map +0 -1
  76. /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
  77. /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
  78. /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
  79. /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
  80. /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
  81. /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
  82. /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
+ });
@@ -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(container: ContentContainer): Promise<Blob> {
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: 'DEFLATE',
54
- compressionOptions: { level: 6 },
67
+ compression,
68
+ ...(compression === 'DEFLATE' ? { compressionOptions: { level: compressionLevel } } : {}),
55
69
  });
56
70
  }
57
71