@bendyline/squisq-formats 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
  3. package/dist/chunk-46FMDOWZ.js.map +1 -0
  4. package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
  5. package/dist/chunk-5LSSWZPU.js.map +1 -0
  6. package/dist/chunk-6M7Z25LA.js +46 -0
  7. package/dist/chunk-6M7Z25LA.js.map +1 -0
  8. package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
  9. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  10. package/dist/chunk-DQAZR57U.js +33 -0
  11. package/dist/chunk-DQAZR57U.js.map +1 -0
  12. package/dist/chunk-EHLGMSTE.js +273 -0
  13. package/dist/chunk-EHLGMSTE.js.map +1 -0
  14. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  15. package/dist/chunk-MJGRI6XR.js.map +1 -0
  16. package/dist/chunk-PN52A5AA.js +32 -0
  17. package/dist/chunk-PN52A5AA.js.map +1 -0
  18. package/dist/chunk-RFAPOKHJ.js +123 -0
  19. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  20. package/dist/chunk-RTS5XBZ7.js +526 -0
  21. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  22. package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
  23. package/dist/chunk-SHLPSADL.js.map +1 -0
  24. package/dist/chunk-SSUPBUF5.js +15 -0
  25. package/dist/chunk-SSUPBUF5.js.map +1 -0
  26. package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
  27. package/dist/chunk-WWBIKRNP.js.map +1 -0
  28. package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
  29. package/dist/chunk-YESS7VY3.js.map +1 -0
  30. package/dist/csv/index.d.ts +50 -0
  31. package/dist/csv/index.js +13 -0
  32. package/dist/csv/index.js.map +1 -0
  33. package/dist/docx/index.js +6 -3
  34. package/dist/epub/index.js +4 -2
  35. package/dist/html/index.d.ts +42 -2
  36. package/dist/html/index.js +9 -2
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +47 -14
  39. package/dist/ooxml/index.d.ts +5 -1
  40. package/dist/ooxml/index.js +13 -9
  41. package/dist/pdf/index.js +2 -1
  42. package/dist/pptx/index.d.ts +36 -19
  43. package/dist/pptx/index.js +9 -2
  44. package/dist/registry/index.d.ts +167 -0
  45. package/dist/registry/index.js +17 -0
  46. package/dist/registry/index.js.map +1 -0
  47. package/dist/xlsx/index.d.ts +60 -29
  48. package/dist/xlsx/index.js +4 -1
  49. package/package.json +12 -2
  50. package/src/__tests__/convert.test.ts +186 -0
  51. package/src/__tests__/csvImport.test.ts +84 -0
  52. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  53. package/src/__tests__/formatRegistry.test.ts +174 -0
  54. package/src/__tests__/htmlImport.test.ts +57 -0
  55. package/src/__tests__/lossyWarnings.test.ts +105 -0
  56. package/src/__tests__/pdfImport.test.ts +95 -1
  57. package/src/__tests__/plainHtml.test.ts +31 -0
  58. package/src/__tests__/pptxExport.test.ts +138 -0
  59. package/src/__tests__/pptxImport.test.ts +115 -0
  60. package/src/__tests__/roundTripAssets.test.ts +50 -0
  61. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  62. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  63. package/src/__tests__/roundTripMatrix.test.ts +142 -0
  64. package/src/__tests__/sharedContainer.test.ts +41 -0
  65. package/src/__tests__/sharedImages.test.ts +61 -0
  66. package/src/__tests__/xlsxExport.test.ts +164 -0
  67. package/src/__tests__/xlsxImport.test.ts +80 -0
  68. package/src/csv/index.ts +188 -0
  69. package/src/docx/export.ts +27 -44
  70. package/src/docx/import.ts +7 -39
  71. package/src/epub/export.ts +10 -27
  72. package/src/html/imageUtils.ts +5 -19
  73. package/src/html/import.ts +297 -0
  74. package/src/html/index.ts +4 -0
  75. package/src/html/plainHtml.ts +66 -16
  76. package/src/index.ts +36 -5
  77. package/src/ooxml/index.ts +3 -0
  78. package/src/ooxml/namespaces.ts +13 -0
  79. package/src/pdf/export.ts +10 -5
  80. package/src/pdf/import.ts +146 -51
  81. package/src/pptx/export.ts +225 -85
  82. package/src/pptx/import.ts +273 -0
  83. package/src/pptx/index.ts +10 -28
  84. package/src/registry/convert.ts +318 -0
  85. package/src/registry/defaultFormats.ts +323 -0
  86. package/src/registry/errors.ts +46 -0
  87. package/src/registry/index.ts +38 -0
  88. package/src/registry/registry.ts +48 -0
  89. package/src/registry/types.ts +107 -0
  90. package/src/shared/container.ts +28 -0
  91. package/src/shared/images.ts +44 -0
  92. package/src/shared/inlineRuns.ts +99 -0
  93. package/src/shared/text.ts +41 -0
  94. package/src/xlsx/export.ts +253 -0
  95. package/src/xlsx/import.ts +159 -0
  96. package/src/xlsx/index.ts +18 -61
  97. package/dist/chunk-33YRFXZZ.js.map +0 -1
  98. package/dist/chunk-67KIJHV2.js +0 -21
  99. package/dist/chunk-67KIJHV2.js.map +0 -1
  100. package/dist/chunk-7DEUGIOO.js.map +0 -1
  101. package/dist/chunk-A3FHLTY5.js.map +0 -1
  102. package/dist/chunk-ERZ627GR.js.map +0 -1
  103. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  104. package/dist/chunk-UDS45KUJ.js.map +0 -1
  105. package/dist/chunk-VN2KEOYB.js.map +0 -1
  106. package/dist/chunk-YN5HFCEW.js +0 -120
  107. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for the consolidated ext→MIME map (`shared/images.ts`) and the html
3
+ * `inferMimeType` wrapper that now delegates to it. This map is the union of
4
+ * the three previously-drifting copies (docx/pptx importers + html export).
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { extToMime } from '../shared/images';
9
+ import { inferMimeType } from '../html/imageUtils';
10
+
11
+ describe('extToMime', () => {
12
+ it('is dot-tolerant and case-insensitive', () => {
13
+ expect(extToMime('png')).toBe('image/png');
14
+ expect(extToMime('.png')).toBe('image/png');
15
+ expect(extToMime('.PNG')).toBe('image/png');
16
+ expect(extToMime('PNG')).toBe('image/png');
17
+ });
18
+
19
+ it('resolves .avif to image/avif (the correct answer where the old maps disagreed)', () => {
20
+ // html's map had avif→image/avif; the docx/pptx maps omitted it entirely
21
+ // (would have fallen back to octet-stream). The union picks image/avif.
22
+ expect(extToMime('avif')).toBe('image/avif');
23
+ expect(extToMime('.avif')).toBe('image/avif');
24
+ });
25
+
26
+ it('covers the union of all three previous maps', () => {
27
+ // Raster/vector (all three had most of these)
28
+ expect(extToMime('jpg')).toBe('image/jpeg');
29
+ expect(extToMime('jpeg')).toBe('image/jpeg');
30
+ expect(extToMime('gif')).toBe('image/gif');
31
+ expect(extToMime('webp')).toBe('image/webp');
32
+ expect(extToMime('bmp')).toBe('image/bmp');
33
+ expect(extToMime('svg')).toBe('image/svg+xml');
34
+ // Only docx/pptx had these
35
+ expect(extToMime('tiff')).toBe('image/tiff');
36
+ expect(extToMime('tif')).toBe('image/tiff');
37
+ expect(extToMime('emf')).toBe('image/emf');
38
+ expect(extToMime('wmf')).toBe('image/wmf');
39
+ // Only html had these
40
+ expect(extToMime('ico')).toBe('image/x-icon');
41
+ expect(extToMime('mp3')).toBe('audio/mpeg');
42
+ expect(extToMime('wav')).toBe('audio/wav');
43
+ expect(extToMime('ogg')).toBe('audio/ogg');
44
+ expect(extToMime('mp4')).toBe('video/mp4');
45
+ expect(extToMime('webm')).toBe('video/webm');
46
+ });
47
+
48
+ it('returns application/octet-stream for unknown / empty extensions', () => {
49
+ expect(extToMime('xyz')).toBe('application/octet-stream');
50
+ expect(extToMime('')).toBe('application/octet-stream');
51
+ });
52
+ });
53
+
54
+ describe('inferMimeType (html wrapper)', () => {
55
+ it('delegates to extToMime, keying off the filename extension', () => {
56
+ expect(inferMimeType('images/hero.PNG')).toBe('image/png');
57
+ expect(inferMimeType('photo.avif')).toBe('image/avif');
58
+ expect(inferMimeType('clip.webm')).toBe('video/webm');
59
+ expect(inferMimeType('noextension')).toBe('application/octet-stream');
60
+ });
61
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import JSZip from 'jszip';
3
+ import { parseMarkdown, type MarkdownDocument } from '@bendyline/squisq/markdown';
4
+ import { markdownToDoc } from '@bendyline/squisq/doc';
5
+
6
+ import { markdownDocToXlsx, docToXlsx } from '../xlsx/export';
7
+ import { xlsxToMarkdownDoc } from '../xlsx/import';
8
+
9
+ /** Read the workbook.xml + all worksheet XML out of an exported .xlsx buffer. */
10
+ async function unzip(buffer: ArrayBuffer): Promise<{
11
+ workbook: string;
12
+ sheets: string[];
13
+ paths: string[];
14
+ }> {
15
+ const zip = await JSZip.loadAsync(buffer);
16
+ const paths = Object.keys(zip.files).sort();
17
+ const workbook = (await zip.file('xl/workbook.xml')?.async('string')) ?? '';
18
+ const sheetPaths = paths.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/.test(p));
19
+ const sheets = await Promise.all(sheetPaths.map((p) => zip.file(p)!.async('string')));
20
+ return { workbook, sheets, paths };
21
+ }
22
+
23
+ /** Extract sheet names (in order) from a workbook.xml string. */
24
+ function sheetNames(workbook: string): string[] {
25
+ return [...workbook.matchAll(/<sheet\s+name="([^"]*)"/g)].map((m) => m[1]!);
26
+ }
27
+
28
+ /** Build a single-heading + single-table document without touching the parser. */
29
+ function docWithHeading(headingText: string): MarkdownDocument {
30
+ return {
31
+ type: 'document',
32
+ children: [
33
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: headingText }] },
34
+ {
35
+ type: 'table',
36
+ children: [
37
+ {
38
+ type: 'tableRow',
39
+ children: [
40
+ { type: 'tableCell', isHeader: true, children: [{ type: 'text', value: 'A' }] },
41
+ ],
42
+ },
43
+ {
44
+ type: 'tableRow',
45
+ children: [{ type: 'tableCell', children: [{ type: 'text', value: 'x' }] }],
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ describe('markdownDocToXlsx', () => {
54
+ it('produces a structurally valid package (content types + rels + workbook)', async () => {
55
+ const md = parseMarkdown('# Data\n\n| A | B |\n| - | - |\n| 1 | 2 |\n');
56
+ const buffer = await markdownDocToXlsx(md);
57
+ const { paths } = await unzip(buffer);
58
+
59
+ expect(paths).toContain('[Content_Types].xml');
60
+ expect(paths).toContain('_rels/.rels');
61
+ expect(paths).toContain('xl/workbook.xml');
62
+ expect(paths).toContain('xl/_rels/workbook.xml.rels');
63
+ expect(paths).toContain('xl/worksheets/sheet1.xml');
64
+ expect(paths).toContain('xl/styles.xml');
65
+ });
66
+
67
+ it('names each sheet from the nearest preceding heading', async () => {
68
+ const md = parseMarkdown('# Sales\n\n| A |\n| - |\n| x |\n');
69
+ const { workbook } = await unzip(await markdownDocToXlsx(md));
70
+ expect(sheetNames(workbook)).toEqual(['Sales']);
71
+ });
72
+
73
+ it('sanitizes invalid Excel characters and caps names at 31 chars', async () => {
74
+ const { workbook } = await unzip(await markdownDocToXlsx(docWithHeading('a:b/c\\d[e]f?g*h')));
75
+ expect(sheetNames(workbook)).toEqual(['abcdefgh']);
76
+
77
+ const { workbook: wb2 } = await unzip(await markdownDocToXlsx(docWithHeading('X'.repeat(50))));
78
+ expect(sheetNames(wb2)[0]!.length).toBe(31);
79
+ });
80
+
81
+ it('de-duplicates repeated sheet names (Name, Name2, Name3, …)', async () => {
82
+ const md = parseMarkdown(
83
+ '# Report\n\n| A |\n| - |\n| 1 |\n\n# Report\n\n| B |\n| - |\n| 2 |\n\n# Report\n\n| C |\n| - |\n| 3 |\n',
84
+ );
85
+ const { workbook } = await unzip(await markdownDocToXlsx(md));
86
+ expect(sheetNames(workbook)).toEqual(['Report', 'Report2', 'Report3']);
87
+ });
88
+
89
+ it('falls back to Sheet1, Sheet2 when a table has no preceding heading', async () => {
90
+ const md = parseMarkdown('| A |\n| - |\n| 1 |\n\n| B |\n| - |\n| 2 |\n');
91
+ const { workbook } = await unzip(await markdownDocToXlsx(md));
92
+ expect(sheetNames(workbook)).toEqual(['Sheet1', 'Sheet2']);
93
+ });
94
+
95
+ it('emits plain numbers as numeric cells and everything else as inline strings', async () => {
96
+ const md = parseMarkdown(
97
+ '# T\n\n| Label | N |\n| - | - |\n| Alpha | 42 |\n| Neg | -3.5 |\n| Mixed | 120 req/s |\n',
98
+ );
99
+ const { sheets } = await unzip(await markdownDocToXlsx(md));
100
+ const xml = sheets[0]!;
101
+
102
+ // Numeric cells: <v>…</v> without t="inlineStr"
103
+ expect(xml).toContain('<v>42</v>');
104
+ expect(xml).toContain('<v>-3.5</v>');
105
+ // String cells: inlineStr
106
+ expect(xml).toContain('t="inlineStr"');
107
+ expect(xml).toContain('>Alpha<');
108
+ expect(xml).toContain('>120 req/s<');
109
+ // "120 req/s" must NOT be numeric
110
+ expect(xml).not.toContain('<v>120 req/s</v>');
111
+ });
112
+
113
+ it('maps multiple tables to multiple worksheets', async () => {
114
+ const md = parseMarkdown('# First\n\n| A |\n| - |\n| 1 |\n\n# Second\n\n| B |\n| - |\n| 2 |\n');
115
+ const { sheets, workbook } = await unzip(await markdownDocToXlsx(md));
116
+ expect(sheets.length).toBe(2);
117
+ expect(sheetNames(workbook)).toEqual(['First', 'Second']);
118
+ });
119
+
120
+ it('produces a valid empty file (single sheet) when there are no tables', async () => {
121
+ const md = parseMarkdown('# Just prose\n\nNo tables here at all.\n');
122
+ const buffer = await markdownDocToXlsx(md);
123
+ const { sheets, workbook } = await unzip(buffer);
124
+ expect(sheets.length).toBe(1);
125
+ expect(sheetNames(workbook)).toEqual(['Sheet1']);
126
+
127
+ // Still round-trips without throwing.
128
+ const roundTrip = await xlsxToMarkdownDoc(buffer);
129
+ expect(roundTrip.type).toBe('document');
130
+ });
131
+
132
+ it('honors a custom sheetNamePrefix for auto-named sheets', async () => {
133
+ const md = parseMarkdown('| A |\n| - |\n| 1 |\n');
134
+ const { workbook } = await unzip(await markdownDocToXlsx(md, { sheetNamePrefix: 'Tab' }));
135
+ expect(sheetNames(workbook)).toEqual(['Tab1']);
136
+ });
137
+
138
+ it('survives a full round-trip (cell text preserved through import)', async () => {
139
+ const md = parseMarkdown(
140
+ '# Metrics\n\n| Metric | Value |\n| - | - |\n| Throughput | 120 |\n| Errors | 0.2% |\n',
141
+ );
142
+ const roundTrip = await xlsxToMarkdownDoc(await markdownDocToXlsx(md));
143
+ const table = roundTrip.children.find((n) => n.type === 'table');
144
+ expect(table).toBeDefined();
145
+
146
+ const flat = JSON.stringify(roundTrip);
147
+ for (const cell of ['Metric', 'Value', 'Throughput', '120', 'Errors', '0.2%']) {
148
+ expect(flat).toContain(cell);
149
+ }
150
+ });
151
+
152
+ it('docToXlsx converts a Doc via the markdown table model', async () => {
153
+ const doc = markdownToDoc(parseMarkdown('# Report\n\n| K | V |\n| - | - |\n| Users | 99 |\n'));
154
+ const buffer = await docToXlsx(doc);
155
+ const { sheets, workbook } = await unzip(buffer);
156
+ expect(sheets.length).toBeGreaterThanOrEqual(1);
157
+ expect(sheetNames(workbook).length).toBeGreaterThanOrEqual(1);
158
+
159
+ const roundTrip = await xlsxToMarkdownDoc(buffer);
160
+ const flat = JSON.stringify(roundTrip);
161
+ expect(flat).toContain('Users');
162
+ expect(flat).toContain('99');
163
+ });
164
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Tests for XLSX import: xlsxToMarkdownDoc. Builds a minimal .xlsx fixture
3
+ * with the shared OOXML writer (dogfooding), then imports it.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import type { MarkdownHeading, MarkdownTable, MarkdownText } from '@bendyline/squisq/markdown';
8
+ import { NS_R, NS_SML, REL_OFFICE_DOCUMENT } from '../ooxml/namespaces';
9
+ import { createPackage } from '../ooxml/writer';
10
+ import { xmlDeclaration } from '../ooxml/xmlUtils';
11
+ import { xlsxToMarkdownDoc } from '../xlsx/import';
12
+
13
+ const REL_WORKSHEET =
14
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet';
15
+ const REL_SHARED_STRINGS =
16
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings';
17
+
18
+ async function buildTestXlsx(): Promise<ArrayBuffer> {
19
+ const pkg = createPackage();
20
+
21
+ pkg.addPart(
22
+ 'xl/workbook.xml',
23
+ `${xmlDeclaration()}<workbook xmlns="${NS_SML}" xmlns:r="${NS_R}">` +
24
+ `<sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets></workbook>`,
25
+ 'application/xml',
26
+ );
27
+ pkg.addPart(
28
+ 'xl/sharedStrings.xml',
29
+ `${xmlDeclaration()}<sst xmlns="${NS_SML}">` +
30
+ `<si><t>Name</t></si><si><t>Age</t></si><si><t>Alice</t></si></sst>`,
31
+ 'application/xml',
32
+ );
33
+ pkg.addPart(
34
+ 'xl/worksheets/sheet1.xml',
35
+ `${xmlDeclaration()}<worksheet xmlns="${NS_SML}"><sheetData>` +
36
+ `<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>` +
37
+ `<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2"><v>30</v></c></row>` +
38
+ `</sheetData></worksheet>`,
39
+ 'application/xml',
40
+ );
41
+
42
+ pkg.addRelationship('', { id: 'rId1', type: REL_OFFICE_DOCUMENT, target: 'xl/workbook.xml' });
43
+ pkg.addRelationship('xl/workbook.xml', {
44
+ id: 'rId1',
45
+ type: REL_WORKSHEET,
46
+ target: 'worksheets/sheet1.xml',
47
+ });
48
+ pkg.addRelationship('xl/workbook.xml', {
49
+ id: 'rId2',
50
+ type: REL_SHARED_STRINGS,
51
+ target: 'sharedStrings.xml',
52
+ });
53
+
54
+ return pkg.toArrayBuffer();
55
+ }
56
+
57
+ describe('xlsxToMarkdownDoc', () => {
58
+ it('imports a sheet as a heading + table, resolving shared strings', async () => {
59
+ const doc = await xlsxToMarkdownDoc(await buildTestXlsx());
60
+ expect(doc.type).toBe('document');
61
+
62
+ const heading = doc.children[0] as MarkdownHeading;
63
+ expect(heading.type).toBe('heading');
64
+ expect((heading.children[0] as MarkdownText).value).toBe('Data');
65
+
66
+ const table = doc.children[1] as MarkdownTable;
67
+ expect(table.type).toBe('table');
68
+ expect(table.children).toHaveLength(2);
69
+ const headerCell = table.children[0]!.children[0]!;
70
+ expect(headerCell.isHeader).toBe(true);
71
+ expect((headerCell.children[0] as MarkdownText).value).toBe('Name');
72
+ expect((table.children[1]!.children[0]!.children[0] as MarkdownText).value).toBe('Alice');
73
+ expect((table.children[1]!.children[1]!.children[0] as MarkdownText).value).toBe('30');
74
+ });
75
+
76
+ it('selects a single sheet by name without a heading', async () => {
77
+ const doc = await xlsxToMarkdownDoc(await buildTestXlsx(), { sheet: 'Data' });
78
+ expect(doc.children[0]!.type).toBe('table');
79
+ });
80
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @bendyline/squisq-formats CSV Module
3
+ *
4
+ * Bridges CSV ↔ the squisq markdown table model. CSV isn't OOXML, so this
5
+ * module is self-contained (no jszip / DOMParser): a small RFC-4180 parser on
6
+ * the import side and a serializer on the export side. The first row is treated
7
+ * as the table header by default.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { csvToMarkdownDoc, markdownDocToCsv } from '@bendyline/squisq-formats/csv';
12
+ * ```
13
+ */
14
+
15
+ import { markdownToDoc } from '@bendyline/squisq/doc';
16
+ import type {
17
+ MarkdownDocument,
18
+ MarkdownTable,
19
+ MarkdownTableCell,
20
+ MarkdownTableRow,
21
+ } from '@bendyline/squisq/markdown';
22
+ import type { Doc } from '@bendyline/squisq/schemas';
23
+
24
+ export interface CsvImportOptions {
25
+ /** Field delimiter. Default `,`. */
26
+ delimiter?: string;
27
+ /** Treat the first row as a header row. Default true. */
28
+ hasHeader?: boolean;
29
+ }
30
+
31
+ export interface CsvExportOptions {
32
+ /** Field delimiter. Default `,`. */
33
+ delimiter?: string;
34
+ /**
35
+ * Zero-based index of the table to export when the document contains more
36
+ * than one. Default 0 (the first table). An explicitly provided index that
37
+ * doesn't match a table in the document is an error.
38
+ */
39
+ tableIndex?: number;
40
+ }
41
+
42
+ async function toText(data: ArrayBuffer | Blob | string): Promise<string> {
43
+ if (typeof data === 'string') return data;
44
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return data.text();
45
+ return new TextDecoder().decode(new Uint8Array(data as ArrayBuffer));
46
+ }
47
+
48
+ /** Parse CSV text into a grid of string cells (RFC 4180: quotes, escaped quotes). */
49
+ export function parseCsv(text: string, delimiter = ','): string[][] {
50
+ const rows: string[][] = [];
51
+ let row: string[] = [];
52
+ let field = '';
53
+ let inQuotes = false;
54
+ let sawAny = false;
55
+ const pushField = () => {
56
+ row.push(field);
57
+ field = '';
58
+ };
59
+ const pushRow = () => {
60
+ pushField();
61
+ rows.push(row);
62
+ row = [];
63
+ };
64
+ for (let i = 0; i < text.length; i++) {
65
+ const ch = text[i]!;
66
+ if (inQuotes) {
67
+ if (ch === '"') {
68
+ if (text[i + 1] === '"') {
69
+ field += '"';
70
+ i++;
71
+ } else {
72
+ inQuotes = false;
73
+ }
74
+ } else {
75
+ field += ch;
76
+ }
77
+ continue;
78
+ }
79
+ if (ch === '"') {
80
+ inQuotes = true;
81
+ sawAny = true;
82
+ continue;
83
+ }
84
+ if (ch === delimiter) {
85
+ sawAny = true;
86
+ pushField();
87
+ continue;
88
+ }
89
+ if (ch === '\n' || ch === '\r') {
90
+ if (ch === '\r' && text[i + 1] === '\n') i++;
91
+ pushRow();
92
+ sawAny = false;
93
+ continue;
94
+ }
95
+ sawAny = true;
96
+ field += ch;
97
+ }
98
+ // Flush a trailing field/row only if the last line wasn't terminated.
99
+ if (field !== '' || row.length > 0 || sawAny) pushRow();
100
+ return rows;
101
+ }
102
+
103
+ function rowsToTable(rows: string[][], hasHeader: boolean): MarkdownTable {
104
+ const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 1);
105
+ const mdRows: MarkdownTableRow[] = rows.map((cells, rowIdx) => {
106
+ const children: MarkdownTableCell[] = [];
107
+ for (let c = 0; c < maxCols; c++) {
108
+ const value = cells[c] ?? '';
109
+ children.push({
110
+ type: 'tableCell',
111
+ ...(hasHeader && rowIdx === 0 ? { isHeader: true } : {}),
112
+ children: value ? [{ type: 'text', value }] : [],
113
+ });
114
+ }
115
+ return { type: 'tableRow', children };
116
+ });
117
+ return { type: 'table', children: mdRows };
118
+ }
119
+
120
+ /** Convert CSV to a MarkdownDocument containing a single table. */
121
+ export async function csvToMarkdownDoc(
122
+ data: ArrayBuffer | Blob | string,
123
+ options: CsvImportOptions = {},
124
+ ): Promise<MarkdownDocument> {
125
+ const text = await toText(data);
126
+ const rows = parseCsv(text, options.delimiter ?? ',');
127
+ const hasHeader = options.hasHeader ?? true;
128
+ const children = rows.length > 0 ? [rowsToTable(rows, hasHeader)] : [];
129
+ return { type: 'document', children };
130
+ }
131
+
132
+ /** Convert CSV to a squisq Doc. */
133
+ export async function csvToDoc(
134
+ data: ArrayBuffer | Blob | string,
135
+ options: CsvImportOptions = {},
136
+ ): Promise<Doc> {
137
+ return markdownToDoc(await csvToMarkdownDoc(data, options));
138
+ }
139
+
140
+ function escapeCsvField(value: string, delimiter: string): string {
141
+ if (value.includes('"') || value.includes(delimiter) || /[\r\n]/.test(value)) {
142
+ return `"${value.replace(/"/g, '""')}"`;
143
+ }
144
+ return value;
145
+ }
146
+
147
+ function cellText(cell: MarkdownTableCell): string {
148
+ // Flatten inline children to plain text (CSV has no formatting).
149
+ const walk = (nodes: unknown[]): string =>
150
+ nodes
151
+ .map((n) => {
152
+ const node = n as { type?: string; value?: string; children?: unknown[] };
153
+ if (node.value !== undefined) return node.value;
154
+ if (Array.isArray(node.children)) return walk(node.children);
155
+ return '';
156
+ })
157
+ .join('');
158
+ return walk(cell.children);
159
+ }
160
+
161
+ /**
162
+ * Serialize one table in a MarkdownDocument to CSV text.
163
+ *
164
+ * By default the first table is exported. Documents with multiple tables can
165
+ * select another via `options.tableIndex` (zero-based). An explicit
166
+ * `tableIndex` that is out of range throws; the implicit first-table default
167
+ * on a table-less document returns an empty string (back-compat).
168
+ */
169
+ export function markdownDocToCsv(doc: MarkdownDocument, options: CsvExportOptions = {}): string {
170
+ const delimiter = options.delimiter ?? ',';
171
+ const tables = doc.children.filter((n): n is MarkdownTable => n.type === 'table');
172
+ const index = options.tableIndex ?? 0;
173
+ if (
174
+ options.tableIndex !== undefined &&
175
+ (!Number.isInteger(index) || index < 0 || index >= tables.length)
176
+ ) {
177
+ throw new Error(
178
+ `CSV export: tableIndex ${index} is out of range — the document contains ${tables.length} table(s).`,
179
+ );
180
+ }
181
+ const table = tables[index];
182
+ if (!table) return '';
183
+ return table.children
184
+ .map((row) =>
185
+ row.children.map((cell) => escapeCsvField(cellText(cell), delimiter)).join(delimiter),
186
+ )
187
+ .join('\r\n');
188
+ }
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
22
- import { docToMarkdown } from '@bendyline/squisq/doc';
21
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
22
+ import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
23
23
  import type {
24
24
  MarkdownDocument,
25
25
  MarkdownBlockNode,
@@ -44,6 +44,12 @@ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
44
44
 
45
45
  import { createPackage } from '../ooxml/writer.js';
46
46
  import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
47
+ import { stripHtmlTags } from '../shared/text.js';
48
+ import {
49
+ inlineNodesToRuns,
50
+ inlineNodeToRuns,
51
+ type InlineRunHandlers,
52
+ } from '../shared/inlineRuns.js';
47
53
  import {
48
54
  NS_WML,
49
55
  NS_R,
@@ -126,7 +132,7 @@ export async function markdownDocToDocx(
126
132
  options.themeId !== undefined
127
133
  ? options
128
134
  : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
129
- const ctx = new ExportContext(resolvedOptions);
135
+ const ctx = new ExportContext(resolvedOptions, doc);
130
136
  const bodyXml = convertBlocks(doc.children, ctx);
131
137
  return buildDocxPackage(bodyXml, ctx, resolvedOptions);
132
138
  }
@@ -206,7 +212,7 @@ class ExportContext {
206
212
 
207
213
  private nextDocPrId = 1;
208
214
 
209
- constructor(options: DocxExportOptions) {
215
+ constructor(options: DocxExportOptions, doc?: MarkdownDocument) {
210
216
  let themeFont: string | undefined;
211
217
  let themeTitleFont: string | undefined;
212
218
  let themeHeadingColor: string | undefined;
@@ -215,7 +221,7 @@ class ExportContext {
215
221
  let themeBackgroundColor: string | undefined;
216
222
 
217
223
  if (options.themeId) {
218
- const theme: Theme = resolveTheme(options.themeId);
224
+ const theme: Theme = resolveThemeForDoc(doc, options.themeId);
219
225
  // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
220
226
  // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
221
227
  // single font name — passing the whole stack is treated as a
@@ -631,46 +637,30 @@ interface InlineFormat {
631
637
  color?: string;
632
638
  }
633
639
 
640
+ /**
641
+ * DOCX leaf handlers for the shared run-based inline walker. The traversal
642
+ * (format threading) lives in `shared/inlineRuns.ts`; these emit WordprocessingML.
643
+ */
644
+ function docxRunHandlers(ctx: ExportContext): InlineRunHandlers {
645
+ return {
646
+ run: (text, format) => makeRun(text, format),
647
+ link: (node, format) => convertLink(node, ctx, format),
648
+ image: (node) => convertImage(node, ctx),
649
+ lineBreak: () => `<w:r><w:br/></w:r>`,
650
+ footnoteRef: (node) => convertFootnoteRef(node, ctx),
651
+ };
652
+ }
653
+
634
654
  function convertInlines(
635
655
  nodes: MarkdownInlineNode[],
636
656
  ctx: ExportContext,
637
657
  format: InlineFormat = {},
638
658
  ): string {
639
- const parts: string[] = [];
640
- for (const node of nodes) {
641
- parts.push(convertInline(node, ctx, format));
642
- }
643
- return parts.join('');
659
+ return inlineNodesToRuns(nodes, docxRunHandlers(ctx), format);
644
660
  }
645
661
 
646
662
  function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: InlineFormat): string {
647
- switch (node.type) {
648
- case 'text':
649
- return makeRun(node.value, format);
650
- case 'strong':
651
- return convertInlines(node.children, ctx, { ...format, bold: true });
652
- case 'emphasis':
653
- return convertInlines(node.children, ctx, { ...format, italic: true });
654
- case 'delete':
655
- return convertInlines(node.children, ctx, { ...format, strike: true });
656
- case 'inlineCode':
657
- return makeRun(node.value, { ...format, code: true });
658
- case 'link':
659
- return convertLink(node, ctx, format);
660
- case 'image':
661
- return convertImage(node, ctx);
662
- case 'break':
663
- return `<w:r><w:br/></w:r>`;
664
- case 'htmlInline':
665
- return makeRun(stripHtmlTags(node.rawHtml), format);
666
- case 'inlineMath':
667
- return makeRun(node.value, { ...format, code: true });
668
- case 'footnoteReference':
669
- return convertFootnoteRef(node, ctx);
670
- default:
671
- // linkReference, imageReference, textDirective — skip or emit plain
672
- return '';
673
- }
663
+ return inlineNodeToRuns(node, docxRunHandlers(ctx), format);
674
664
  }
675
665
 
676
666
  function makeRun(text: string, format: InlineFormat): string {
@@ -1273,10 +1263,3 @@ function buildFootnotesXml(ctx: ExportContext): string {
1273
1263
  // ============================================
1274
1264
  // Helpers
1275
1265
  // ============================================
1276
-
1277
- /**
1278
- * Strip HTML tags from a string, keeping only text content.
1279
- */
1280
- function stripHtmlTags(html: string): string {
1281
- return html.replace(/<[^>]*>/g, '');
1282
- }