@bendyline/squisq-formats 1.4.0 → 1.4.2

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 (83) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-CRTC6DEX.js → chunk-46FMDOWZ.js} +2 -2
  3. package/dist/{chunk-ON232HJR.js → chunk-5LSSWZPU.js} +2 -2
  4. package/dist/{chunk-APSIAEXL.js → chunk-D7IFPWNZ.js} +87 -43
  5. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  6. package/dist/chunk-DQAZR57U.js +33 -0
  7. package/dist/chunk-DQAZR57U.js.map +1 -0
  8. package/dist/chunk-EHLGMSTE.js +273 -0
  9. package/dist/chunk-EHLGMSTE.js.map +1 -0
  10. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  11. package/dist/chunk-MJGRI6XR.js.map +1 -0
  12. package/dist/{chunk-JBNVE2GV.js → chunk-RFAPOKHJ.js} +9 -2
  13. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  14. package/dist/chunk-RTS5XBZ7.js +526 -0
  15. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  16. package/dist/{chunk-FMOIGA5T.js → chunk-SHLPSADL.js} +178 -5
  17. package/dist/chunk-SHLPSADL.js.map +1 -0
  18. package/dist/chunk-SSUPBUF5.js +15 -0
  19. package/dist/chunk-SSUPBUF5.js.map +1 -0
  20. package/dist/{chunk-BJQIJ4S4.js → chunk-WWBIKRNP.js} +17 -41
  21. package/dist/chunk-WWBIKRNP.js.map +1 -0
  22. package/dist/{chunk-I2GFTEOV.js → chunk-YESS7VY3.js} +77 -15
  23. package/dist/{chunk-I2GFTEOV.js.map → chunk-YESS7VY3.js.map} +1 -1
  24. package/dist/csv/index.d.ts +14 -1
  25. package/dist/csv/index.js +1 -1
  26. package/dist/docx/index.js +4 -3
  27. package/dist/epub/index.js +3 -2
  28. package/dist/html/index.d.ts +3 -0
  29. package/dist/html/index.js +3 -2
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +26 -11
  32. package/dist/ooxml/index.d.ts +5 -1
  33. package/dist/ooxml/index.js +16 -12
  34. package/dist/pdf/index.js +2 -1
  35. package/dist/pptx/index.d.ts +18 -3
  36. package/dist/pptx/index.js +7 -4
  37. package/dist/registry/index.d.ts +167 -0
  38. package/dist/registry/index.js +17 -0
  39. package/dist/registry/index.js.map +1 -0
  40. package/dist/xlsx/index.d.ts +44 -14
  41. package/dist/xlsx/index.js +4 -2
  42. package/package.json +7 -2
  43. package/src/__tests__/convert.test.ts +186 -0
  44. package/src/__tests__/csvImport.test.ts +34 -1
  45. package/src/__tests__/formatRegistry.test.ts +174 -0
  46. package/src/__tests__/lossyWarnings.test.ts +105 -0
  47. package/src/__tests__/pdfImport.test.ts +95 -1
  48. package/src/__tests__/pptxImport.test.ts +46 -1
  49. package/src/__tests__/roundTripAssets.test.ts +50 -0
  50. package/src/__tests__/roundTripMatrix.test.ts +10 -4
  51. package/src/__tests__/sharedContainer.test.ts +41 -0
  52. package/src/__tests__/sharedImages.test.ts +61 -0
  53. package/src/__tests__/xlsxExport.test.ts +164 -0
  54. package/src/csv/index.ts +25 -2
  55. package/src/docx/import.ts +6 -37
  56. package/src/html/imageUtils.ts +5 -19
  57. package/src/index.ts +29 -5
  58. package/src/ooxml/index.ts +3 -0
  59. package/src/ooxml/namespaces.ts +10 -0
  60. package/src/pdf/import.ts +145 -50
  61. package/src/pptx/import.ts +127 -10
  62. package/src/pptx/index.ts +1 -1
  63. package/src/registry/convert.ts +318 -0
  64. package/src/registry/defaultFormats.ts +323 -0
  65. package/src/registry/errors.ts +46 -0
  66. package/src/registry/index.ts +38 -0
  67. package/src/registry/registry.ts +48 -0
  68. package/src/registry/types.ts +107 -0
  69. package/src/shared/container.ts +28 -0
  70. package/src/shared/images.ts +44 -0
  71. package/src/xlsx/export.ts +253 -0
  72. package/src/xlsx/index.ts +10 -36
  73. package/dist/chunk-A3FHLTY5.js.map +0 -1
  74. package/dist/chunk-APSIAEXL.js.map +0 -1
  75. package/dist/chunk-BJQIJ4S4.js.map +0 -1
  76. package/dist/chunk-FMOIGA5T.js.map +0 -1
  77. package/dist/chunk-JBNVE2GV.js.map +0 -1
  78. package/dist/chunk-L53YIGYS.js +0 -141
  79. package/dist/chunk-L53YIGYS.js.map +0 -1
  80. package/dist/chunk-QSE7EWE7.js +0 -181
  81. package/dist/chunk-QSE7EWE7.js.map +0 -1
  82. /package/dist/{chunk-CRTC6DEX.js.map → chunk-46FMDOWZ.js.map} +0 -0
  83. /package/dist/{chunk-ON232HJR.js.map → chunk-5LSSWZPU.js.map} +0 -0
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, it } from 'vitest';
6
- import type { MarkdownTable, MarkdownText } from '@bendyline/squisq/markdown';
6
+ import type { MarkdownDocument, MarkdownTable, MarkdownText } from '@bendyline/squisq/markdown';
7
7
  import { csvToMarkdownDoc, markdownDocToCsv, parseCsv } from '../csv/index';
8
8
 
9
9
  function cellValue(table: MarkdownTable, row: number, col: number): string {
@@ -49,3 +49,36 @@ describe('csvToMarkdownDoc', () => {
49
49
  expect(markdownDocToCsv(doc)).toBe(csv);
50
50
  });
51
51
  });
52
+
53
+ describe('markdownDocToCsv tableIndex', () => {
54
+ async function docWithTwoTables(): Promise<MarkdownDocument> {
55
+ const first = await csvToMarkdownDoc('a,b\r\n1,2');
56
+ const second = await csvToMarkdownDoc('c,d\r\n3,4');
57
+ return { type: 'document', children: [...first.children, ...second.children] };
58
+ }
59
+
60
+ it('exports the first table by default', async () => {
61
+ const doc = await docWithTwoTables();
62
+ expect(markdownDocToCsv(doc)).toBe('a,b\r\n1,2');
63
+ });
64
+
65
+ it('picks the Nth table via tableIndex', async () => {
66
+ const doc = await docWithTwoTables();
67
+ expect(markdownDocToCsv(doc, { tableIndex: 0 })).toBe('a,b\r\n1,2');
68
+ expect(markdownDocToCsv(doc, { tableIndex: 1 })).toBe('c,d\r\n3,4');
69
+ });
70
+
71
+ it('throws a clear error for an out-of-range tableIndex', async () => {
72
+ const doc = await docWithTwoTables();
73
+ expect(() => markdownDocToCsv(doc, { tableIndex: 2 })).toThrow(
74
+ 'CSV export: tableIndex 2 is out of range — the document contains 2 table(s).',
75
+ );
76
+ expect(() => markdownDocToCsv(doc, { tableIndex: -1 })).toThrow('out of range');
77
+ });
78
+
79
+ it('returns an empty string for a table-less document with the implicit default', () => {
80
+ const doc: MarkdownDocument = { type: 'document', children: [] };
81
+ expect(markdownDocToCsv(doc)).toBe('');
82
+ expect(() => markdownDocToCsv(doc, { tableIndex: 0 })).toThrow('out of range');
83
+ });
84
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for the format registry: createRegistry / defaultRegistry, capability
3
+ * consistency across every built-in, and the structured error codes.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
8
+ import { markdownToDoc } from '@bendyline/squisq/doc';
9
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
10
+ import {
11
+ createRegistry,
12
+ defaultRegistry,
13
+ defaultFormats,
14
+ ConversionError,
15
+ BUILTIN_FORMAT_IDS,
16
+ } from '../registry/index';
17
+ import type { FormatDefinition, NormalizedInput } from '../registry/index';
18
+
19
+ const SAMPLE_MD = `# Title
20
+
21
+ Some intro text.
22
+
23
+ | Name | Value |
24
+ | --- | --- |
25
+ | A | 1 |
26
+ | B | 2 |
27
+ `;
28
+
29
+ async function makeInput(baseName = 'sample'): Promise<NormalizedInput> {
30
+ const markdownDoc = parseMarkdown(SAMPLE_MD);
31
+ const doc = markdownToDoc(markdownDoc);
32
+ const container = new MemoryContentContainer();
33
+ await container.writeDocument(SAMPLE_MD);
34
+ return { doc, markdownDoc, container, baseName };
35
+ }
36
+
37
+ const stubDef = (id: string, extensions: string[]): FormatDefinition => ({
38
+ id,
39
+ label: id.toUpperCase(),
40
+ mimeType: 'application/octet-stream',
41
+ extensions,
42
+ });
43
+
44
+ // ── Registry mechanics ──────────────────────────────────────────────
45
+
46
+ describe('createRegistry', () => {
47
+ it('registers and gets definitions by id', () => {
48
+ const reg = createRegistry();
49
+ reg.register(stubDef('foo', ['.foo']));
50
+ expect(reg.get('foo')?.id).toBe('foo');
51
+ expect(reg.get('missing')).toBeUndefined();
52
+ });
53
+
54
+ it('resolves byExtension, normalizing leading dot + case', () => {
55
+ const reg = createRegistry();
56
+ reg.register(stubDef('foo', ['.foo', '.f']));
57
+ expect(reg.byExtension('foo')?.id).toBe('foo');
58
+ expect(reg.byExtension('.FOO')?.id).toBe('foo');
59
+ expect(reg.byExtension('f')?.id).toBe('foo');
60
+ expect(reg.byExtension('nope')).toBeUndefined();
61
+ });
62
+
63
+ it('is last-write-wins by id', () => {
64
+ const reg = createRegistry();
65
+ reg.register(stubDef('foo', ['.foo']));
66
+ reg.register({ ...stubDef('foo', ['.foo']), label: 'SECOND' });
67
+ expect(reg.get('foo')?.label).toBe('SECOND');
68
+ expect(reg.list().filter((d) => d.id === 'foo')).toHaveLength(1);
69
+ });
70
+
71
+ it('lists all registered definitions', () => {
72
+ const reg = createRegistry();
73
+ reg.register(stubDef('a', ['.a']));
74
+ reg.register(stubDef('b', ['.b']));
75
+ expect(
76
+ reg
77
+ .list()
78
+ .map((d) => d.id)
79
+ .sort(),
80
+ ).toEqual(['a', 'b']);
81
+ });
82
+ });
83
+
84
+ describe('defaultRegistry', () => {
85
+ it('registers all built-in formats', () => {
86
+ const reg = defaultRegistry();
87
+ for (const id of BUILTIN_FORMAT_IDS) {
88
+ expect(reg.get(id), `built-in "${id}" should be registered`).toBeDefined();
89
+ }
90
+ expect(reg.list()).toHaveLength(BUILTIN_FORMAT_IDS.length);
91
+ });
92
+
93
+ it('defaultFormats matches BUILTIN_FORMAT_IDS exactly', () => {
94
+ const ids = defaultFormats()
95
+ .map((d) => d.id)
96
+ .sort();
97
+ expect(ids).toEqual([...BUILTIN_FORMAT_IDS].sort());
98
+ });
99
+ });
100
+
101
+ // ── Capability consistency ──────────────────────────────────────────
102
+
103
+ describe('built-in capability consistency', () => {
104
+ const formats = defaultFormats();
105
+
106
+ for (const def of formats) {
107
+ if (!def.exportDoc) continue;
108
+ it(`${def.id}: exportDoc produces bytes (or a tolerated not-implemented error)`, async () => {
109
+ const input = await makeInput();
110
+ // HTML formats need a player script; provide a trivial stub.
111
+ const options = { resolvePlayerScript: async () => '/* stub */' };
112
+ try {
113
+ const result = await def.exportDoc!(input, options);
114
+ // Use ArrayBuffer.isView rather than `instanceof Uint8Array`: under the
115
+ // jsdom test env, bytes built from node's TextEncoder/Uint8Array live in
116
+ // a different realm than the test file's Uint8Array global.
117
+ expect(ArrayBuffer.isView(result.bytes)).toBe(true);
118
+ expect(result.bytes.byteLength).toBeGreaterThan(0);
119
+ expect(typeof result.mimeType).toBe('string');
120
+ expect(Array.isArray(result.warnings)).toBe(true);
121
+ } catch (err: unknown) {
122
+ // xlsx export is being implemented concurrently and may still throw
123
+ // 'not yet implemented'. Tolerate that specific case only.
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ expect(def.id, `unexpected export failure for "${def.id}": ${message}`).toBe('xlsx');
126
+ expect(message).toMatch(/not yet implemented/i);
127
+ }
128
+ });
129
+ }
130
+
131
+ it('csv export warns when the document has multiple tables', async () => {
132
+ const multiTable = parseMarkdown(
133
+ `| A | B |\n| - | - |\n| 1 | 2 |\n\n| C | D |\n| - | - |\n| 3 | 4 |\n`,
134
+ );
135
+ const container = new MemoryContentContainer();
136
+ const input: NormalizedInput = {
137
+ doc: markdownToDoc(multiTable),
138
+ markdownDoc: multiTable,
139
+ container,
140
+ baseName: 'tables',
141
+ };
142
+ const csv = defaultFormats().find((d) => d.id === 'csv')!;
143
+ const result = await csv.exportDoc!(input, {});
144
+ expect(result.warnings.length).toBeGreaterThan(0);
145
+ expect(result.warnings[0]).toMatch(/table/i);
146
+ });
147
+ });
148
+
149
+ // ── Error codes ─────────────────────────────────────────────────────
150
+
151
+ describe('error codes', () => {
152
+ it('ConversionError carries a stable code + optional fields', () => {
153
+ const err = new ConversionError('invalid-input', 'boom', {
154
+ format: 'docx',
155
+ hint: 'try again',
156
+ cause: new Error('root'),
157
+ });
158
+ expect(err).toBeInstanceOf(Error);
159
+ expect(err.name).toBe('ConversionError');
160
+ expect(err.code).toBe('invalid-input');
161
+ expect(err.format).toBe('docx');
162
+ expect(err.hint).toBe('try again');
163
+ expect((err as Error & { cause?: unknown }).cause).toBeInstanceOf(Error);
164
+ });
165
+
166
+ it('html export without a player script → missing-dependency', async () => {
167
+ const input = await makeInput();
168
+ const html = defaultFormats().find((d) => d.id === 'html')!;
169
+ await expect(html.exportDoc!(input, {})).rejects.toMatchObject({
170
+ name: 'ConversionError',
171
+ code: 'missing-dependency',
172
+ });
173
+ });
174
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Tests for the registry's consolidated lossy-path warnings, all surfaced
3
+ * through `ConversionResult.warnings`:
4
+ * - xlsx export drops non-table content (tables-only fidelity)
5
+ * - pdf import under Node skips embedded images (no DOM canvas)
6
+ * - transform → markdown produces blocks that don't round-trip
7
+ */
8
+
9
+ import { describe, it, expect, afterEach, vi } from 'vitest';
10
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
11
+ import { convert, defaultRegistry } from '../registry/index';
12
+
13
+ // ── xlsx: tables-only fidelity ──────────────────────────────────────
14
+
15
+ describe('xlsx export warns about omitted non-table content', () => {
16
+ it('warns when prose / lists accompany the table(s)', async () => {
17
+ const md =
18
+ '# Report\n\nIntro prose paragraph.\n\n- a\n- b\n\n| A | B |\n| - | - |\n| 1 | 2 |\n';
19
+ const result = await convert({ kind: 'markdown', markdown: md }, 'xlsx');
20
+ expect(result.warnings.some((w) => /tables-only/i.test(w))).toBe(true);
21
+ // Two omitted blocks: the paragraph and the list (heading names the sheet).
22
+ expect(result.warnings.find((w) => /tables-only/i.test(w))).toMatch(/2 non-table block/);
23
+ });
24
+
25
+ it('does not warn for a heading + table only document', async () => {
26
+ const md = '# Sales\n\n| A |\n| - |\n| x |\n';
27
+ const result = await convert({ kind: 'markdown', markdown: md }, 'xlsx');
28
+ expect(result.warnings.some((w) => /tables-only/i.test(w))).toBe(false);
29
+ });
30
+ });
31
+
32
+ // ── pdf: Node has no DOM canvas → images skipped ────────────────────
33
+
34
+ describe('pdf import warns when running without a DOM (Node)', () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ /** A pdf definition whose importContainer is stubbed (avoids pdfjs/canvas). */
40
+ function registryWithStubPdf() {
41
+ const registry = defaultRegistry();
42
+ registry.register({
43
+ ...registry.get('pdf')!,
44
+ async importContainer() {
45
+ const c = new MemoryContentContainer();
46
+ await c.writeDocument('# From PDF\n\nBody.');
47
+ return c;
48
+ },
49
+ });
50
+ return registry;
51
+ }
52
+
53
+ const pdfBytes = new TextEncoder().encode('%PDF-1.4\n%mock\n');
54
+
55
+ it('surfaces the skipped-images warning when document is undefined', async () => {
56
+ vi.stubGlobal('document', undefined);
57
+ const registry = registryWithStubPdf();
58
+ const result = await convert({ kind: 'bytes', data: pdfBytes, filename: 'x.pdf' }, 'md', {
59
+ from: 'pdf',
60
+ registry,
61
+ });
62
+ expect(result.warnings.some((w) => /embedded images were skipped/i.test(w))).toBe(true);
63
+ });
64
+
65
+ it('does not warn when a DOM is present (jsdom default)', async () => {
66
+ const registry = registryWithStubPdf();
67
+ const result = await convert({ kind: 'bytes', data: pdfBytes, filename: 'x.pdf' }, 'md', {
68
+ from: 'pdf',
69
+ registry,
70
+ });
71
+ expect(result.warnings.some((w) => /embedded images were skipped/i.test(w))).toBe(false);
72
+ });
73
+ });
74
+
75
+ // ── transform → markdown round-trip loss ────────────────────────────
76
+
77
+ describe('transform-to-markdown export warns about non-round-tripping blocks', () => {
78
+ const CONTENT_MD = `# Big Title
79
+
80
+ An intro paragraph with plenty of words so the transform has real content.
81
+
82
+ ## Numbers
83
+
84
+ Revenue grew by 45% last quarter across every region and product line worldwide.
85
+
86
+ - Point one with several words to analyze here
87
+ - Point two with several words to analyze here
88
+
89
+ | Metric | Value |
90
+ | - | - |
91
+ | Users | 1000 |
92
+ `;
93
+
94
+ it('warns when a transform reshapes into template blocks that markdown can’t hold', async () => {
95
+ const result = await convert({ kind: 'markdown', markdown: CONTENT_MD }, 'md', {
96
+ transformStyle: 'magazine',
97
+ });
98
+ expect(result.warnings.some((w) => /round-trip to markdown/i.test(w))).toBe(true);
99
+ });
100
+
101
+ it('does not warn without a transform', async () => {
102
+ const result = await convert({ kind: 'markdown', markdown: CONTENT_MD }, 'md');
103
+ expect(result.warnings.some((w) => /round-trip to markdown/i.test(w))).toBe(false);
104
+ });
105
+ });
@@ -11,11 +11,13 @@
11
11
  import { describe, it, expect } from 'vitest';
12
12
  import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
13
13
 
14
- import { pdfToMarkdownDoc, pdfToDoc } from '../pdf/import';
14
+ import { pdfToMarkdownDoc, pdfToDoc, insertImageBlocks } from '../pdf/import';
15
+ import type { ExtractedImage } from '../pdf/import';
15
16
  import type {
16
17
  MarkdownHeading,
17
18
  MarkdownBlockNode,
18
19
  MarkdownCodeBlock,
20
+ MarkdownParagraph,
19
21
  } from '@bendyline/squisq/markdown';
20
22
 
21
23
  /** Structural shape shared by all markdown node types, for recursive walkers. */
@@ -276,6 +278,98 @@ describe('pdfToMarkdownDoc', () => {
276
278
  // pdfToDoc convenience wrapper
277
279
  // ============================================
278
280
 
281
+ // ============================================
282
+ // insertImageBlocks — page-level image placement (pure)
283
+ // ============================================
284
+
285
+ describe('insertImageBlocks', () => {
286
+ /** Build a synthetic paragraph block carrying a marker text value. */
287
+ function para(label: string): MarkdownParagraph {
288
+ return { type: 'paragraph', children: [{ type: 'text', value: label }] };
289
+ }
290
+
291
+ /** Build a synthetic extracted image on a given page (data/y are irrelevant here). */
292
+ function img(index: number, page: number): ExtractedImage {
293
+ return { path: `images/image${index}.png`, data: new ArrayBuffer(0), page, y: 0 };
294
+ }
295
+
296
+ /** Extract a flat list of block descriptors: paragraph label or image url. */
297
+ function describeBlocks(blocks: MarkdownBlockNode[]): string[] {
298
+ return blocks.map((b) => {
299
+ if (b.type === 'paragraph') {
300
+ const child = (b as MarkdownParagraph).children[0];
301
+ if (child && child.type === 'image') return `img:${child.url}`;
302
+ if (child && child.type === 'text') return `p:${child.value}`;
303
+ }
304
+ return b.type;
305
+ });
306
+ }
307
+
308
+ it('is a no-op when there are no images', () => {
309
+ const blocks = [para('A'), para('B')];
310
+ const pages = [0, 0];
311
+ const result = insertImageBlocks(blocks, pages, []);
312
+ expect(result).toBe(blocks);
313
+ });
314
+
315
+ it('places an image after the last block of its page', () => {
316
+ // Page 0: A, B Page 1: C
317
+ const blocks = [para('A'), para('B'), para('C')];
318
+ const pages = [0, 0, 1];
319
+ const result = insertImageBlocks(blocks, pages, [img(1, 0)]);
320
+ expect(describeBlocks(result)).toEqual(['p:A', 'p:B', 'img:images/image1.png', 'p:C']);
321
+ });
322
+
323
+ it('places images on the correct page in a multi-page document', () => {
324
+ // Page 0: A Page 1: B Page 2: C
325
+ const blocks = [para('A'), para('B'), para('C')];
326
+ const pages = [0, 1, 2];
327
+ const result = insertImageBlocks(blocks, pages, [img(1, 2), img(2, 0)]);
328
+ // image2 → after A (page 0), image1 → after C (page 2)
329
+ expect(describeBlocks(result)).toEqual([
330
+ 'p:A',
331
+ 'img:images/image2.png',
332
+ 'p:B',
333
+ 'p:C',
334
+ 'img:images/image1.png',
335
+ ]);
336
+ });
337
+
338
+ it('preserves image order within the same page', () => {
339
+ const blocks = [para('A'), para('B')];
340
+ const pages = [0, 0];
341
+ const result = insertImageBlocks(blocks, pages, [img(1, 0), img(2, 0)]);
342
+ expect(describeBlocks(result)).toEqual([
343
+ 'p:A',
344
+ 'p:B',
345
+ 'img:images/image1.png',
346
+ 'img:images/image2.png',
347
+ ]);
348
+ });
349
+
350
+ it('falls back to the nearest preceding page for an image-only page', () => {
351
+ // Page 0: A Page 1: (no blocks) image on page 1 → after A
352
+ const blocks = [para('A'), para('B')];
353
+ const pages = [0, 2];
354
+ // Image on page 1 has no blocks; nearest preceding page with blocks is 0 → after A.
355
+ const result = insertImageBlocks(blocks, pages, [img(1, 1)]);
356
+ expect(describeBlocks(result)).toEqual(['p:A', 'img:images/image1.png', 'p:B']);
357
+ });
358
+
359
+ it('appends at the end when no preceding page has blocks', () => {
360
+ // Only page 5 has blocks; image on page 2 has no preceding page → append at end.
361
+ const blocks = [para('A'), para('B')];
362
+ const pages = [5, 5];
363
+ const result = insertImageBlocks(blocks, pages, [img(1, 2)]);
364
+ expect(describeBlocks(result)).toEqual(['p:A', 'p:B', 'img:images/image1.png']);
365
+ });
366
+
367
+ it('appends all images when there are no text blocks', () => {
368
+ const result = insertImageBlocks([], [], [img(1, 0), img(2, 3)]);
369
+ expect(describeBlocks(result)).toEqual(['img:images/image1.png', 'img:images/image2.png']);
370
+ });
371
+ });
372
+
279
373
  describe('pdfToDoc', () => {
280
374
  it('converts PDF to a Doc object', async () => {
281
375
  const buffer = await buildSimplePdf([
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { describe, expect, it } from 'vitest';
7
7
  import type {
8
+ MarkdownDocument,
8
9
  MarkdownHeading,
9
10
  MarkdownList,
10
11
  MarkdownParagraph,
@@ -13,7 +14,11 @@ import type {
13
14
  import { NS_DRAWINGML, NS_PML, NS_R, REL_OFFICE_DOCUMENT, REL_SLIDE } from '../ooxml/namespaces';
14
15
  import { createPackage } from '../ooxml/writer';
15
16
  import { xmlDeclaration } from '../ooxml/xmlUtils';
16
- import { pptxToMarkdownDoc } from '../pptx/import';
17
+ import { markdownDocToPptx } from '../pptx/export';
18
+ import { pptxToContainer, pptxToMarkdownDoc } from '../pptx/import';
19
+
20
+ /** Distinctive PNG-signature-prefixed payload so round-trip fidelity is checkable. */
21
+ const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]);
17
22
 
18
23
  async function buildTestPptx(): Promise<ArrayBuffer> {
19
24
  const pkg = createPackage();
@@ -68,3 +73,43 @@ describe('pptxToMarkdownDoc', () => {
68
73
  expect((firstItemPara.children[0] as MarkdownText).value).toBe('First bullet');
69
74
  });
70
75
  });
76
+
77
+ describe('pptxToContainer (embedded image import)', () => {
78
+ const deckWithImage: MarkdownDocument = {
79
+ type: 'document',
80
+ children: [
81
+ { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Picture Slide' }] },
82
+ { type: 'paragraph', children: [{ type: 'image', url: 'photo.png', alt: 'A photo' }] },
83
+ ],
84
+ };
85
+
86
+ async function buildDeck(): Promise<ArrayBuffer> {
87
+ return markdownDocToPptx(deckWithImage, {
88
+ images: new Map([['photo.png', IMAGE_BYTES.buffer]]),
89
+ });
90
+ }
91
+
92
+ it('extracts embedded images into the container and references them', async () => {
93
+ const container = await pptxToContainer(await buildDeck());
94
+
95
+ // The extracted image exists at the docx-style images/imageN.ext path.
96
+ expect(await container.exists('images/image1.png')).toBe(true);
97
+
98
+ // The bytes survived the round-trip verbatim.
99
+ const bytes = await container.readFile('images/image1.png');
100
+ expect(bytes).not.toBeNull();
101
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
102
+
103
+ // The markdown references the extracted image (no dangling ref).
104
+ const markdown = await container.readDocument();
105
+ expect(markdown).toContain('images/image1.png');
106
+ });
107
+
108
+ it('does not emit dangling image refs without extractImages', async () => {
109
+ const doc = await pptxToMarkdownDoc(await buildDeck());
110
+ const hasImage = doc.children.some(
111
+ (b) => b.type === 'paragraph' && b.children.some((c) => c.type === 'image'),
112
+ );
113
+ expect(hasImage).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Asset-fidelity guard: an embedded image must survive a full export → import
3
+ * round-trip for every container-producing format. We author a markdown doc
4
+ * with an image, export it (supplying the image bytes), re-import via the
5
+ * format's `*ToContainer`, and assert the bytes + reference come back intact.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
10
+ import { markdownDocToPptx } from '../pptx/export';
11
+ import { pptxToContainer } from '../pptx/import';
12
+ import { markdownDocToDocx } from '../docx/export';
13
+ import { docxToContainer } from '../docx/import';
14
+
15
+ /** PNG-signature-prefixed payload with a distinctive tail for byte comparison. */
16
+ const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 8, 7, 6]);
17
+
18
+ const doc: MarkdownDocument = {
19
+ type: 'document',
20
+ children: [
21
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Assets' }] },
22
+ { type: 'paragraph', children: [{ type: 'image', url: 'hero.png', alt: 'Hero' }] },
23
+ ],
24
+ };
25
+
26
+ describe('embedded image round-trips through container formats', () => {
27
+ it('survives markdown → pptx → pptxToContainer', async () => {
28
+ const pptx = await markdownDocToPptx(doc, {
29
+ images: new Map([['hero.png', IMAGE_BYTES.buffer]]),
30
+ });
31
+ const container = await pptxToContainer(pptx);
32
+
33
+ expect(await container.exists('images/image1.png')).toBe(true);
34
+ const bytes = await container.readFile('images/image1.png');
35
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
36
+ expect(await container.readDocument()).toContain('images/image1.png');
37
+ });
38
+
39
+ it('survives markdown → docx → docxToContainer', async () => {
40
+ const docx = await markdownDocToDocx(doc, {
41
+ images: new Map([['hero.png', { data: IMAGE_BYTES.buffer, contentType: 'image/png' }]]),
42
+ });
43
+ const container = await docxToContainer(docx);
44
+
45
+ expect(await container.exists('images/image1.png')).toBe(true);
46
+ const bytes = await container.readFile('images/image1.png');
47
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
48
+ expect(await container.readDocument()).toContain('images/image1.png');
49
+ });
50
+ });
@@ -12,6 +12,7 @@ import { markdownDocToCsv, csvToMarkdownDoc } from '../csv/index';
12
12
  import { markdownDocToEpub } from '../epub/export';
13
13
  import { docToHtml } from '../html/index';
14
14
  import { markdownDocToXlsx } from '../xlsx/index';
15
+ import { xlsxToMarkdownDoc } from '../xlsx/import';
15
16
  import { assertHybridRoundTrip, extractNormalizedText } from './roundTripMatrix.helpers';
16
17
  import { ROUNDTRIP_FIXTURES } from './roundTripMatrix.fixtures';
17
18
 
@@ -128,9 +129,14 @@ describe('format content-flow matrix', () => {
128
129
  expect(csvText).toContain('throughput');
129
130
  });
130
131
 
131
- it('asserts current XLSX export stub behavior', async () => {
132
- await expect(markdownDocToXlsx(ROUNDTRIP_FIXTURES.table.doc)).rejects.toThrow(
133
- 'XLSX export is not yet implemented',
134
- );
132
+ it('round-trips markdown through XLSX with table cell survival', async () => {
133
+ const source = ROUNDTRIP_FIXTURES.table;
134
+ const xlsx = await markdownDocToXlsx(source.doc);
135
+ const roundTrip = await xlsxToMarkdownDoc(xlsx);
136
+
137
+ const text = extractNormalizedText(roundTrip);
138
+ for (const phrase of source.keyPhrases) {
139
+ expect(text).toContain(phrase);
140
+ }
135
141
  });
136
142
  });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tests for the shared container-assembly tail (`shared/container.ts`) that the
3
+ * docx / pptx / pdf `*ToContainer` importers now delegate to.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { buildContainer } from '../shared/container';
8
+
9
+ const BYTES_A = new Uint8Array([1, 2, 3, 4]);
10
+ const BYTES_B = new Uint8Array([9, 8, 7]);
11
+
12
+ describe('buildContainer', () => {
13
+ it('writes the markdown document and every image with its mime type', async () => {
14
+ const container = await buildContainer(
15
+ '# Hello\n\n![](images/a.png)',
16
+ new Map([
17
+ ['images/a.png', { data: BYTES_A.buffer, mimeType: 'image/png' }],
18
+ ['images/b.jpg', { data: BYTES_B.buffer, mimeType: 'image/jpeg' }],
19
+ ]),
20
+ );
21
+
22
+ expect(await container.readDocument()).toContain('Hello');
23
+ expect(await container.exists('images/a.png')).toBe(true);
24
+ expect(await container.exists('images/b.jpg')).toBe(true);
25
+ expect(new Uint8Array((await container.readFile('images/a.png'))!)).toEqual(BYTES_A);
26
+ expect(new Uint8Array((await container.readFile('images/b.jpg'))!)).toEqual(BYTES_B);
27
+ });
28
+
29
+ it('accepts any iterable of [path, {data, mimeType}] (e.g. mapped array)', async () => {
30
+ const images = [{ path: 'images/x.png', data: BYTES_A.buffer }].map(
31
+ (i) => [i.path, { data: i.data, mimeType: 'image/png' }] as const,
32
+ );
33
+ const container = await buildContainer('# Doc', images);
34
+ expect(await container.exists('images/x.png')).toBe(true);
35
+ });
36
+
37
+ it('produces a valid empty container with no images', async () => {
38
+ const container = await buildContainer('', []);
39
+ expect(await container.readDocument()).toBe('');
40
+ });
41
+ });