@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.
- package/README.md +180 -43
- package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
- package/dist/chunk-46FMDOWZ.js.map +1 -0
- package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
- package/dist/chunk-5LSSWZPU.js.map +1 -0
- package/dist/chunk-6M7Z25LA.js +46 -0
- package/dist/chunk-6M7Z25LA.js.map +1 -0
- package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
- package/dist/chunk-D7IFPWNZ.js.map +1 -0
- package/dist/chunk-DQAZR57U.js +33 -0
- package/dist/chunk-DQAZR57U.js.map +1 -0
- package/dist/chunk-EHLGMSTE.js +273 -0
- package/dist/chunk-EHLGMSTE.js.map +1 -0
- package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
- package/dist/chunk-MJGRI6XR.js.map +1 -0
- package/dist/chunk-PN52A5AA.js +32 -0
- package/dist/chunk-PN52A5AA.js.map +1 -0
- package/dist/chunk-RFAPOKHJ.js +123 -0
- package/dist/chunk-RFAPOKHJ.js.map +1 -0
- package/dist/chunk-RTS5XBZ7.js +526 -0
- package/dist/chunk-RTS5XBZ7.js.map +1 -0
- package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
- package/dist/chunk-SHLPSADL.js.map +1 -0
- package/dist/chunk-SSUPBUF5.js +15 -0
- package/dist/chunk-SSUPBUF5.js.map +1 -0
- package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
- package/dist/chunk-WWBIKRNP.js.map +1 -0
- package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
- package/dist/chunk-YESS7VY3.js.map +1 -0
- package/dist/csv/index.d.ts +50 -0
- package/dist/csv/index.js +13 -0
- package/dist/csv/index.js.map +1 -0
- package/dist/docx/index.js +6 -3
- package/dist/epub/index.js +4 -2
- package/dist/html/index.d.ts +42 -2
- package/dist/html/index.js +9 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +47 -14
- package/dist/ooxml/index.d.ts +5 -1
- package/dist/ooxml/index.js +13 -9
- package/dist/pdf/index.js +2 -1
- package/dist/pptx/index.d.ts +36 -19
- package/dist/pptx/index.js +9 -2
- package/dist/registry/index.d.ts +167 -0
- package/dist/registry/index.js +17 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/xlsx/index.d.ts +60 -29
- package/dist/xlsx/index.js +4 -1
- package/package.json +12 -2
- package/src/__tests__/convert.test.ts +186 -0
- package/src/__tests__/csvImport.test.ts +84 -0
- package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
- package/src/__tests__/formatRegistry.test.ts +174 -0
- package/src/__tests__/htmlImport.test.ts +57 -0
- package/src/__tests__/lossyWarnings.test.ts +105 -0
- package/src/__tests__/pdfImport.test.ts +95 -1
- package/src/__tests__/plainHtml.test.ts +31 -0
- package/src/__tests__/pptxExport.test.ts +138 -0
- package/src/__tests__/pptxImport.test.ts +115 -0
- package/src/__tests__/roundTripAssets.test.ts +50 -0
- package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
- package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
- package/src/__tests__/roundTripMatrix.test.ts +142 -0
- package/src/__tests__/sharedContainer.test.ts +41 -0
- package/src/__tests__/sharedImages.test.ts +61 -0
- package/src/__tests__/xlsxExport.test.ts +164 -0
- package/src/__tests__/xlsxImport.test.ts +80 -0
- package/src/csv/index.ts +188 -0
- package/src/docx/export.ts +27 -44
- package/src/docx/import.ts +7 -39
- package/src/epub/export.ts +10 -27
- package/src/html/imageUtils.ts +5 -19
- package/src/html/import.ts +297 -0
- package/src/html/index.ts +4 -0
- package/src/html/plainHtml.ts +66 -16
- package/src/index.ts +36 -5
- package/src/ooxml/index.ts +3 -0
- package/src/ooxml/namespaces.ts +13 -0
- package/src/pdf/export.ts +10 -5
- package/src/pdf/import.ts +146 -51
- package/src/pptx/export.ts +225 -85
- package/src/pptx/import.ts +273 -0
- package/src/pptx/index.ts +10 -28
- package/src/registry/convert.ts +318 -0
- package/src/registry/defaultFormats.ts +323 -0
- package/src/registry/errors.ts +46 -0
- package/src/registry/index.ts +38 -0
- package/src/registry/registry.ts +48 -0
- package/src/registry/types.ts +107 -0
- package/src/shared/container.ts +28 -0
- package/src/shared/images.ts +44 -0
- package/src/shared/inlineRuns.ts +99 -0
- package/src/shared/text.ts +41 -0
- package/src/xlsx/export.ts +253 -0
- package/src/xlsx/import.ts +159 -0
- package/src/xlsx/index.ts +18 -61
- package/dist/chunk-33YRFXZZ.js.map +0 -1
- package/dist/chunk-67KIJHV2.js +0 -21
- package/dist/chunk-67KIJHV2.js.map +0 -1
- package/dist/chunk-7DEUGIOO.js.map +0 -1
- package/dist/chunk-A3FHLTY5.js.map +0 -1
- package/dist/chunk-ERZ627GR.js.map +0 -1
- package/dist/chunk-S3Y7H2BK.js.map +0 -1
- package/dist/chunk-UDS45KUJ.js.map +0 -1
- package/dist/chunk-VN2KEOYB.js.map +0 -1
- package/dist/chunk-YN5HFCEW.js +0 -120
- 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',
|
|
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
|
+
});
|
package/src/csv/index.ts
ADDED
|
@@ -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
|
+
}
|
package/src/docx/export.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Doc, Theme } from '@bendyline/squisq/schemas';
|
|
21
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|