@bendyline/squisq-formats 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +19 -0
- package/README.md +2 -2
- package/dist/{chunk-NNHKUXKA.js → chunk-26ISNJ7Y.js} +85 -65
- package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
- package/dist/{chunk-KURGXM4I.js → chunk-4V3KCHAP.js} +3 -4
- package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
- package/dist/{chunk-EW54IRRS.js → chunk-6S6GU3ZG.js} +5 -6
- package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
- package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
- package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
- package/dist/{chunk-SC67HYQJ.js → chunk-EJTNGKEA.js} +5 -8
- package/dist/{chunk-WQSHGBLN.js → chunk-G6J326JW.js} +193 -42
- package/dist/chunk-GX7RAUME.js +121 -0
- package/dist/{chunk-GVS2XXV6.js → chunk-GYIVES2E.js} +485 -59
- package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
- package/dist/{chunk-RLU7UFYU.js → chunk-IPN56VLW.js} +83 -58
- package/dist/{chunk-DTDF6QDP.js → chunk-JE6LSIHE.js} +81 -20
- package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
- package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
- package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
- package/dist/chunk-OGS5VCGJ.js +446 -0
- package/dist/{chunk-74GO3FVS.js → chunk-PU7REGWV.js} +5 -8
- package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
- package/dist/{chunk-QFLDYKCR.js → chunk-TAAENIRB.js} +5 -8
- package/dist/{chunk-7ARKUCQT.js → chunk-X2DEAXNK.js} +54 -2
- package/dist/container/index.js +1 -2
- package/dist/csv/index.d.ts +27 -2
- package/dist/csv/index.js +1 -2
- package/dist/docx/index.d.ts +5 -1
- package/dist/docx/index.js +9 -11
- package/dist/epub/index.d.ts +2 -0
- package/dist/epub/index.js +5 -6
- package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
- package/dist/extract-MN7LA3NL.js +13 -0
- package/dist/html/index.d.ts +11 -4
- package/dist/html/index.js +3 -4
- package/dist/images-ESPQKVTW.js +6 -0
- package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
- package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +28 -26
- package/dist/infer/index.d.ts +3 -3
- package/dist/infer/index.js +7 -9
- package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
- package/dist/layouts-DRWZGSPD.js +10 -0
- package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
- package/dist/ooxml/index.d.ts +3 -3
- package/dist/ooxml/index.js +14 -13
- package/dist/pdf/index.d.ts +18 -0
- package/dist/pdf/index.js +2 -3
- package/dist/pptx/index.d.ts +4 -4
- package/dist/pptx/index.js +11 -13
- package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
- package/dist/registry/index.d.ts +21 -5
- package/dist/registry/index.js +9 -6
- package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
- package/dist/xlsx/index.d.ts +3 -3
- package/dist/xlsx/index.js +6 -7
- package/package.json +10 -4
- package/dist/chunk-4VUWTSGM.js.map +0 -1
- package/dist/chunk-6M7Z25LA.js +0 -46
- package/dist/chunk-6M7Z25LA.js.map +0 -1
- package/dist/chunk-74GO3FVS.js.map +0 -1
- package/dist/chunk-7ARKUCQT.js.map +0 -1
- package/dist/chunk-DTDF6QDP.js.map +0 -1
- package/dist/chunk-EW54IRRS.js.map +0 -1
- package/dist/chunk-FE6OJV6O.js.map +0 -1
- package/dist/chunk-GVS2XXV6.js.map +0 -1
- package/dist/chunk-KURGXM4I.js.map +0 -1
- package/dist/chunk-MLX2BOJC.js.map +0 -1
- package/dist/chunk-NKAJPJ4G.js.map +0 -1
- package/dist/chunk-NNHKUXKA.js.map +0 -1
- package/dist/chunk-O3GVVND4.js.map +0 -1
- package/dist/chunk-ODL3SSPT.js.map +0 -1
- package/dist/chunk-PN52A5AA.js.map +0 -1
- package/dist/chunk-QFLDYKCR.js.map +0 -1
- package/dist/chunk-RFAPOKHJ.js.map +0 -1
- package/dist/chunk-RLU7UFYU.js.map +0 -1
- package/dist/chunk-SC67HYQJ.js.map +0 -1
- package/dist/chunk-SSUPBUF5.js.map +0 -1
- package/dist/chunk-U4MRIFKL.js.map +0 -1
- package/dist/chunk-UGYF5AZE.js +0 -275
- package/dist/chunk-UGYF5AZE.js.map +0 -1
- package/dist/chunk-WQSHGBLN.js.map +0 -1
- package/dist/chunk-YRT7GQ5Y.js +0 -28
- package/dist/chunk-YRT7GQ5Y.js.map +0 -1
- package/dist/container/index.js.map +0 -1
- package/dist/csv/index.js.map +0 -1
- package/dist/docx/index.js.map +0 -1
- package/dist/epub/index.js.map +0 -1
- package/dist/extract-OJ7ZQV6P.js +0 -15
- package/dist/extract-OJ7ZQV6P.js.map +0 -1
- package/dist/html/index.js.map +0 -1
- package/dist/images-7FBWPKE3.js +0 -7
- package/dist/images-7FBWPKE3.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/infer/index.js.map +0 -1
- package/dist/layouts-5VDIRPIJ.js +0 -12
- package/dist/layouts-5VDIRPIJ.js.map +0 -1
- package/dist/mapTheme-IR27S6IV.js.map +0 -1
- package/dist/ooxml/index.js.map +0 -1
- package/dist/pdf/index.js.map +0 -1
- package/dist/pptx/index.js.map +0 -1
- package/dist/registry/index.js.map +0 -1
- package/dist/xlsx/index.js.map +0 -1
- package/src/__tests__/container.test.ts +0 -230
- package/src/__tests__/convert.test.ts +0 -495
- package/src/__tests__/csvImport.test.ts +0 -84
- package/src/__tests__/docxExport.test.ts +0 -491
- package/src/__tests__/docxImport.test.ts +0 -531
- package/src/__tests__/epub.test.ts +0 -649
- package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
- package/src/__tests__/formatRegistry.test.ts +0 -174
- package/src/__tests__/html.test.ts +0 -439
- package/src/__tests__/htmlImport.test.ts +0 -57
- package/src/__tests__/inferTheme.test.ts +0 -135
- package/src/__tests__/lossyWarnings.test.ts +0 -146
- package/src/__tests__/ooxml.test.ts +0 -271
- package/src/__tests__/ooxmlCancellation.test.ts +0 -113
- package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
- package/src/__tests__/pdfExport.test.ts +0 -322
- package/src/__tests__/pdfImport.test.ts +0 -384
- package/src/__tests__/plainHtml.test.ts +0 -417
- package/src/__tests__/plainHtmlBundle.test.ts +0 -253
- package/src/__tests__/pptxExport.test.ts +0 -138
- package/src/__tests__/pptxImport.test.ts +0 -145
- package/src/__tests__/pptxInferFixtures.ts +0 -314
- package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
- package/src/__tests__/roundTrip.test.ts +0 -201
- package/src/__tests__/roundTripAssets.test.ts +0 -50
- package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
- package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
- package/src/__tests__/roundTripMatrix.test.ts +0 -142
- package/src/__tests__/sharedContainer.test.ts +0 -41
- package/src/__tests__/sharedImages.test.ts +0 -61
- package/src/__tests__/xlsxExport.test.ts +0 -164
- package/src/__tests__/xlsxImport.test.ts +0 -80
- package/src/__tests__/zipSafety.test.ts +0 -317
- package/src/container/index.ts +0 -94
- package/src/csv/index.ts +0 -188
- package/src/docx/export.ts +0 -1375
- package/src/docx/import.ts +0 -1250
- package/src/docx/index.ts +0 -26
- package/src/docx/styles.ts +0 -145
- package/src/epub/export.ts +0 -968
- package/src/epub/index.ts +0 -20
- package/src/html/docsHtmlBundle.ts +0 -373
- package/src/html/htmlTemplate.ts +0 -385
- package/src/html/imageUtils.ts +0 -61
- package/src/html/import.ts +0 -297
- package/src/html/index.ts +0 -212
- package/src/html/plainHtml.ts +0 -790
- package/src/html/plainHtmlBundle.ts +0 -421
- package/src/index.ts +0 -109
- package/src/infer/extract.ts +0 -127
- package/src/infer/index.ts +0 -199
- package/src/infer/mapTheme.ts +0 -176
- package/src/infer/types.ts +0 -27
- package/src/ooxml/index.ts +0 -111
- package/src/ooxml/namespaces.ts +0 -217
- package/src/ooxml/readUtils.ts +0 -44
- package/src/ooxml/reader.ts +0 -318
- package/src/ooxml/themeReader.ts +0 -197
- package/src/ooxml/types.ts +0 -103
- package/src/ooxml/writer.ts +0 -339
- package/src/ooxml/xmlUtils.ts +0 -123
- package/src/pdf/export.ts +0 -1084
- package/src/pdf/import.ts +0 -1164
- package/src/pdf/index.ts +0 -29
- package/src/pdf/styles.ts +0 -180
- package/src/pptx/export.ts +0 -1184
- package/src/pptx/import.ts +0 -455
- package/src/pptx/index.ts +0 -52
- package/src/pptx/layouts.ts +0 -1222
- package/src/pptx/styles.ts +0 -96
- package/src/pptx/templates.ts +0 -187
- package/src/registry/convert.ts +0 -433
- package/src/registry/defaultFormats.ts +0 -413
- package/src/registry/errors.ts +0 -46
- package/src/registry/index.ts +0 -43
- package/src/registry/registry.ts +0 -48
- package/src/registry/types.ts +0 -170
- package/src/shared/boundedZipArchive.ts +0 -383
- package/src/shared/container.ts +0 -28
- package/src/shared/fidelity.ts +0 -130
- package/src/shared/images.ts +0 -44
- package/src/shared/inlineRuns.ts +0 -99
- package/src/shared/text.ts +0 -41
- package/src/shared/zipEntryCount.ts +0 -151
- package/src/shared/zipLimits.ts +0 -296
- package/src/shared/zipSafety.ts +0 -19
- package/src/xlsx/export.ts +0 -253
- package/src/xlsx/import.ts +0 -160
- package/src/xlsx/index.ts +0 -35
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import JSZip from 'jszip';
|
|
3
|
-
import { getPartXml, openPackage } from '../ooxml/reader';
|
|
4
|
-
import type { OoxmlPackage } from '../ooxml/types';
|
|
5
|
-
import { openBoundedZipArchive, validateZipArchive, ZipSafetyError } from '../shared/zipSafety';
|
|
6
|
-
import { declaredZipEntryCount } from '../shared/zipEntryCount';
|
|
7
|
-
|
|
8
|
-
interface InstrumentedStream {
|
|
9
|
-
pause(): InstrumentedStream;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface StreamableEntry {
|
|
13
|
-
internalStream(type: 'uint8array'): InstrumentedStream;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function makeZip(
|
|
17
|
-
files: Record<string, string | Uint8Array>,
|
|
18
|
-
compression: 'STORE' | 'DEFLATE' = 'DEFLATE',
|
|
19
|
-
): Promise<Uint8Array> {
|
|
20
|
-
const zip = new JSZip();
|
|
21
|
-
for (const [path, data] of Object.entries(files)) zip.file(path, data);
|
|
22
|
-
return zip.generateAsync({ type: 'uint8array', compression });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function patchMemberMetadata(
|
|
26
|
-
source: Uint8Array,
|
|
27
|
-
path: string,
|
|
28
|
-
patch: { uncompressedSize?: number; crc32?: number },
|
|
29
|
-
): Uint8Array {
|
|
30
|
-
const bytes = source.slice();
|
|
31
|
-
const view = new DataView(bytes.buffer);
|
|
32
|
-
let patchedCentral = false;
|
|
33
|
-
|
|
34
|
-
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
35
|
-
const signature = view.getUint32(offset, true);
|
|
36
|
-
let nameOffset: number;
|
|
37
|
-
let nameLengthOffset: number;
|
|
38
|
-
let uncompressedSizeOffset: number;
|
|
39
|
-
let crcOffset: number;
|
|
40
|
-
if (signature === 0x02014b50) {
|
|
41
|
-
nameOffset = offset + 46;
|
|
42
|
-
nameLengthOffset = offset + 28;
|
|
43
|
-
uncompressedSizeOffset = offset + 24;
|
|
44
|
-
crcOffset = offset + 16;
|
|
45
|
-
} else if (signature === 0x04034b50) {
|
|
46
|
-
nameOffset = offset + 30;
|
|
47
|
-
nameLengthOffset = offset + 26;
|
|
48
|
-
uncompressedSizeOffset = offset + 22;
|
|
49
|
-
crcOffset = offset + 14;
|
|
50
|
-
} else {
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
55
|
-
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
56
|
-
if (name !== path) continue;
|
|
57
|
-
if (patch.uncompressedSize !== undefined) {
|
|
58
|
-
view.setUint32(uncompressedSizeOffset, patch.uncompressedSize, true);
|
|
59
|
-
}
|
|
60
|
-
if (patch.crc32 !== undefined) view.setUint32(crcOffset, patch.crc32, true);
|
|
61
|
-
if (signature === 0x02014b50) patchedCentral = true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
expect(patchedCentral).toBe(true);
|
|
65
|
-
return bytes;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function renameZipMember(source: Uint8Array, from: string, to: string): Uint8Array {
|
|
69
|
-
expect(to.length).toBe(from.length);
|
|
70
|
-
const bytes = source.slice();
|
|
71
|
-
const view = new DataView(bytes.buffer);
|
|
72
|
-
const replacement = new TextEncoder().encode(to);
|
|
73
|
-
let renamedCentral = false;
|
|
74
|
-
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
75
|
-
const signature = view.getUint32(offset, true);
|
|
76
|
-
const isCentral = signature === 0x02014b50;
|
|
77
|
-
const isLocal = signature === 0x04034b50;
|
|
78
|
-
if (!isCentral && !isLocal) continue;
|
|
79
|
-
const nameLengthOffset = offset + (isCentral ? 28 : 26);
|
|
80
|
-
const nameOffset = offset + (isCentral ? 46 : 30);
|
|
81
|
-
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
82
|
-
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
83
|
-
if (name !== from) continue;
|
|
84
|
-
bytes.set(replacement, nameOffset);
|
|
85
|
-
if (isCentral) renamedCentral = true;
|
|
86
|
-
}
|
|
87
|
-
expect(renamedCentral).toBe(true);
|
|
88
|
-
return bytes;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
describe('bounded JSZip reads', () => {
|
|
92
|
-
it('preflights duplicate central records before JSZip collapses their names', async () => {
|
|
93
|
-
const valid = await makeZip({ 'one.txt': '1', 'two.txt': '2' }, 'STORE');
|
|
94
|
-
const duplicate = renameZipMember(valid, 'two.txt', 'one.txt');
|
|
95
|
-
const parsed = await JSZip.loadAsync(duplicate);
|
|
96
|
-
expect(Object.values(parsed.files).filter((entry) => !entry.dir)).toHaveLength(1);
|
|
97
|
-
|
|
98
|
-
const blob = new Blob([duplicate.slice().buffer]);
|
|
99
|
-
await expect(openBoundedZipArchive(blob, { maxEntries: 1 })).rejects.toMatchObject({
|
|
100
|
-
code: 'too-many-entries',
|
|
101
|
-
limit: 1,
|
|
102
|
-
actual: 2,
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('counts directory-only central records toward the entry limit', async () => {
|
|
107
|
-
const zip = new JSZip();
|
|
108
|
-
zip.folder('one');
|
|
109
|
-
zip.folder('two');
|
|
110
|
-
const data = await zip.generateAsync({ type: 'uint8array' });
|
|
111
|
-
await expect(openBoundedZipArchive(data, { maxEntries: 1 })).rejects.toMatchObject({
|
|
112
|
-
code: 'too-many-entries',
|
|
113
|
-
actual: 2,
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('finds the EOCD record with a comment containing signature-like bytes', async () => {
|
|
118
|
-
const zip = new JSZip();
|
|
119
|
-
zip.file('one.txt', '1');
|
|
120
|
-
zip.file('two.txt', '2');
|
|
121
|
-
const data = await zip.generateAsync({
|
|
122
|
-
type: 'uint8array',
|
|
123
|
-
comment: 'looks like PK\u0005\u0006 but is only a comment',
|
|
124
|
-
});
|
|
125
|
-
expect(declaredZipEntryCount(data)).toBe(2);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('reads ZIP64 record counts and falls back when ZIP64 metadata is ambiguous', () => {
|
|
129
|
-
const zip64 = new Uint8Array(98);
|
|
130
|
-
const view = new DataView(zip64.buffer);
|
|
131
|
-
view.setUint32(0, 0x06064b50, true);
|
|
132
|
-
view.setUint32(32, 3, true);
|
|
133
|
-
view.setUint32(56, 0x07064b50, true);
|
|
134
|
-
view.setUint32(64, 0, true);
|
|
135
|
-
view.setUint32(76, 0x06054b50, true);
|
|
136
|
-
view.setUint16(86, 0xffff, true);
|
|
137
|
-
expect(declaredZipEntryCount(zip64)).toBe(3);
|
|
138
|
-
|
|
139
|
-
zip64.fill(0, 56, 76);
|
|
140
|
-
expect(declaredZipEntryCount(zip64)).toBeUndefined();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('maps Blob read failures to a structured invalid-archive error', async () => {
|
|
144
|
-
const unreadable = {
|
|
145
|
-
size: 10,
|
|
146
|
-
slice: () => unreadable,
|
|
147
|
-
arrayBuffer: () => Promise.reject(new Error('read failed')),
|
|
148
|
-
} as unknown as Blob;
|
|
149
|
-
await expect(openBoundedZipArchive(unreadable)).rejects.toMatchObject({
|
|
150
|
-
code: 'invalid-archive',
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('returns structured errors while preserving established messages', async () => {
|
|
155
|
-
const data = await makeZip({ 'large.txt': '12345' }, 'STORE');
|
|
156
|
-
let error: unknown;
|
|
157
|
-
try {
|
|
158
|
-
await openBoundedZipArchive(data, { maxEntryUncompressedBytes: 4 });
|
|
159
|
-
} catch (caught: unknown) {
|
|
160
|
-
error = caught;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
expect(error).toBeInstanceOf(ZipSafetyError);
|
|
164
|
-
expect(error).toMatchObject({
|
|
165
|
-
name: 'ZipSafetyError',
|
|
166
|
-
code: 'entry-too-large',
|
|
167
|
-
path: 'large.txt',
|
|
168
|
-
limit: 4,
|
|
169
|
-
actual: 5,
|
|
170
|
-
});
|
|
171
|
-
expect((error as Error).message).toMatch(/exceeds 4 byte per-entry limit/);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('rejects a high-ratio member from central-directory metadata', async () => {
|
|
175
|
-
const data = await makeZip({ 'repeated.bin': new Uint8Array(128 * 1024) });
|
|
176
|
-
await expect(openBoundedZipArchive(data, { maxCompressionRatio: 2 })).rejects.toMatchObject({
|
|
177
|
-
code: 'compression-ratio-exceeded',
|
|
178
|
-
path: 'repeated.bin',
|
|
179
|
-
limit: 2,
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('enforces the default compression-ratio ceiling against a single-member bomb', async () => {
|
|
184
|
-
const data = await makeZip({ 'zeros.bin': new Uint8Array(2 * 1024 * 1024) });
|
|
185
|
-
await expect(openBoundedZipArchive(data)).rejects.toMatchObject({
|
|
186
|
-
code: 'compression-ratio-exceeded',
|
|
187
|
-
path: 'zeros.bin',
|
|
188
|
-
limit: 1000,
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('halts an underdeclared member on its first emitted chunk without retaining it', async () => {
|
|
193
|
-
const valid = await makeZip({ 'bomb.txt': new Uint8Array(1024 * 1024) });
|
|
194
|
-
const forged = patchMemberMetadata(valid, 'bomb.txt', { uncompressedSize: 1 });
|
|
195
|
-
const archive = await openBoundedZipArchive(forged);
|
|
196
|
-
|
|
197
|
-
await expect(archive.read('bomb.txt')).rejects.toMatchObject({
|
|
198
|
-
code: 'size-mismatch',
|
|
199
|
-
path: 'bomb.txt',
|
|
200
|
-
});
|
|
201
|
-
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('pauses every concurrent JSZip stream when one read breaches its bound', async () => {
|
|
205
|
-
const data = await makeZip(
|
|
206
|
-
{
|
|
207
|
-
'one.bin': new Uint8Array(64 * 1024),
|
|
208
|
-
'two.bin': new Uint8Array(64 * 1024),
|
|
209
|
-
},
|
|
210
|
-
'STORE',
|
|
211
|
-
);
|
|
212
|
-
const archive = await openBoundedZipArchive(data);
|
|
213
|
-
const pauseSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
214
|
-
|
|
215
|
-
for (const metadata of archive.entries) {
|
|
216
|
-
const entry = metadata.entry as unknown as StreamableEntry;
|
|
217
|
-
const original = entry.internalStream.bind(entry);
|
|
218
|
-
entry.internalStream = ((type: 'uint8array') => {
|
|
219
|
-
const stream = original(type);
|
|
220
|
-
const pause = vi.fn(stream.pause.bind(stream));
|
|
221
|
-
stream.pause = pause;
|
|
222
|
-
pauseSpies.push(pause);
|
|
223
|
-
return stream;
|
|
224
|
-
}) as StreamableEntry['internalStream'];
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const results = await Promise.allSettled([
|
|
228
|
-
archive.read('one.bin', 1),
|
|
229
|
-
archive.read('two.bin', 1),
|
|
230
|
-
]);
|
|
231
|
-
expect(results.every((result) => result.status === 'rejected')).toBe(true);
|
|
232
|
-
expect(pauseSpies).toHaveLength(2);
|
|
233
|
-
expect(pauseSpies.every((pause) => pause.mock.calls.length > 0)).toBe(true);
|
|
234
|
-
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('caches successful part reads without charging the aggregate budget twice', async () => {
|
|
238
|
-
const data = await makeZip({ 'part.xml': '<part />' }, 'STORE');
|
|
239
|
-
const archive = await openBoundedZipArchive(data);
|
|
240
|
-
const first = await archive.read('part.xml');
|
|
241
|
-
const emittedAfterFirstRead = archive.emittedUncompressedBytes;
|
|
242
|
-
const second = await archive.read('part.xml');
|
|
243
|
-
|
|
244
|
-
expect(second).toBe(first);
|
|
245
|
-
expect(archive.emittedUncompressedBytes).toBe(emittedAfterFirstRead);
|
|
246
|
-
expect(emittedAfterFirstRead).toBe(new TextEncoder().encode('<part />').byteLength);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('checks CRC32 while streaming instead of eagerly inflating during load', async () => {
|
|
250
|
-
const valid = await makeZip({ 'content.txt': 'integrity' }, 'STORE');
|
|
251
|
-
const forged = patchMemberMetadata(valid, 'content.txt', { crc32: 0 });
|
|
252
|
-
const archive = await openBoundedZipArchive(forged);
|
|
253
|
-
|
|
254
|
-
await expect(archive.read('content.txt')).rejects.toMatchObject({
|
|
255
|
-
code: 'crc-mismatch',
|
|
256
|
-
path: 'content.txt',
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('does not reject an otherwise valid JSZip object solely for unavailable internals', async () => {
|
|
261
|
-
const data = await makeZip({ 'content.txt': 'okay' }, 'STORE');
|
|
262
|
-
const zip = await JSZip.loadAsync(data);
|
|
263
|
-
const entry = zip.file('content.txt') as unknown as {
|
|
264
|
-
_data: { uncompressedSize?: number; compressedSize?: number; crc32?: number };
|
|
265
|
-
};
|
|
266
|
-
delete entry._data.uncompressedSize;
|
|
267
|
-
delete entry._data.compressedSize;
|
|
268
|
-
delete entry._data.crc32;
|
|
269
|
-
|
|
270
|
-
expect(() => validateZipArchive(zip)).not.toThrow();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('rejects manually constructed OOXML packages that bypass bounded archive opening', async () => {
|
|
274
|
-
const forged = {
|
|
275
|
-
contentTypes: { overrides: new Map(), defaults: new Map() },
|
|
276
|
-
rootRelationships: [],
|
|
277
|
-
} as unknown as OoxmlPackage;
|
|
278
|
-
|
|
279
|
-
await expect(getPartXml(forged, 'custom.xml')).rejects.toThrow(
|
|
280
|
-
'Invalid OoxmlPackage: create packages with openPackage().',
|
|
281
|
-
);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('bounds mandatory OOXML metadata independently of large media allowances', async () => {
|
|
285
|
-
const oversizedContentTypes = `<Types>${' '.repeat(1024 * 1024)}</Types>`;
|
|
286
|
-
const data = await makeZip(
|
|
287
|
-
{
|
|
288
|
-
'[Content_Types].xml': oversizedContentTypes,
|
|
289
|
-
'word/media/large.bin': new Uint8Array(2 * 1024 * 1024),
|
|
290
|
-
},
|
|
291
|
-
'STORE',
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
295
|
-
code: 'entry-too-large',
|
|
296
|
-
path: '[Content_Types].xml',
|
|
297
|
-
limit: 1024 * 1024,
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it('bounds OOXML relationship metadata before DOM parsing', async () => {
|
|
302
|
-
const oversizedRelationships = `<Relationships>${' '.repeat(4 * 1024 * 1024)}</Relationships>`;
|
|
303
|
-
const data = await makeZip(
|
|
304
|
-
{
|
|
305
|
-
'[Content_Types].xml': '<Types />',
|
|
306
|
-
'_rels/.rels': oversizedRelationships,
|
|
307
|
-
},
|
|
308
|
-
'STORE',
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
312
|
-
code: 'entry-too-large',
|
|
313
|
-
path: '_rels/.rels',
|
|
314
|
-
limit: 4 * 1024 * 1024,
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
|
-
});
|
package/src/container/index.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Container ZIP serialization — convert between ContentContainer and ZIP archives.
|
|
3
|
-
*
|
|
4
|
-
* Uses JSZip (already a formats dependency) to serialize a ContentContainer to
|
|
5
|
-
* a ZIP blob and to deserialize a ZIP blob into a MemoryContentContainer.
|
|
6
|
-
*
|
|
7
|
-
* ZIP structure mirrors the container's flat path hierarchy directly:
|
|
8
|
-
* index.md
|
|
9
|
-
* images/hero.jpg
|
|
10
|
-
* audio/narration.mp3
|
|
11
|
-
* timing.json
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import JSZip from 'jszip';
|
|
15
|
-
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
16
|
-
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
17
|
-
import {
|
|
18
|
-
assertSafeZipPath,
|
|
19
|
-
openBoundedZipArchive,
|
|
20
|
-
ZipSafetyError,
|
|
21
|
-
type ZipSafetyLimits,
|
|
22
|
-
type ZipSafetyErrorCode,
|
|
23
|
-
type ZipSafetyErrorOptions,
|
|
24
|
-
} from '../shared/zipSafety.js';
|
|
25
|
-
|
|
26
|
-
export type ZipToContainerOptions = ZipSafetyLimits;
|
|
27
|
-
export interface ContainerToZipOptions {
|
|
28
|
-
/** STORE avoids producing archives that violate strict import compression-ratio policies. */
|
|
29
|
-
compression?: 'STORE' | 'DEFLATE';
|
|
30
|
-
/** JSZip DEFLATE level; ignored for STORE. */
|
|
31
|
-
compressionLevel?: number;
|
|
32
|
-
}
|
|
33
|
-
export { ZipSafetyError };
|
|
34
|
-
export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Serialize a ContentContainer to a ZIP blob.
|
|
38
|
-
*
|
|
39
|
-
* All files in the container are written to the ZIP archive preserving
|
|
40
|
-
* their path structure. The resulting blob can be saved as a .zip file.
|
|
41
|
-
*
|
|
42
|
-
* @param container — The container to serialize
|
|
43
|
-
* @returns A Blob containing the ZIP archive
|
|
44
|
-
*/
|
|
45
|
-
export async function containerToZip(
|
|
46
|
-
container: ContentContainer,
|
|
47
|
-
options: ContainerToZipOptions = {},
|
|
48
|
-
): Promise<Blob> {
|
|
49
|
-
const zip = new JSZip();
|
|
50
|
-
const entries = await container.listFiles();
|
|
51
|
-
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
assertSafeZipPath(entry.path);
|
|
54
|
-
const data = await container.readFile(entry.path);
|
|
55
|
-
if (data) {
|
|
56
|
-
zip.file(entry.path, new Uint8Array(data));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const compression = options.compression ?? 'DEFLATE';
|
|
61
|
-
const compressionLevel = options.compressionLevel ?? 6;
|
|
62
|
-
if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 1 || compressionLevel > 9) {
|
|
63
|
-
throw new Error('ZIP compression level must be an integer between 1 and 9');
|
|
64
|
-
}
|
|
65
|
-
return zip.generateAsync({
|
|
66
|
-
type: 'blob',
|
|
67
|
-
compression,
|
|
68
|
-
...(compression === 'DEFLATE' ? { compressionOptions: { level: compressionLevel } } : {}),
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Deserialize a ZIP archive into a MemoryContentContainer.
|
|
74
|
-
*
|
|
75
|
-
* Reads all files from the ZIP and writes them into a new MemoryContentContainer.
|
|
76
|
-
* Directory entries are skipped. The resulting container can be used immediately
|
|
77
|
-
* for rendering, editing, or saving to persistent storage.
|
|
78
|
-
*
|
|
79
|
-
* @param zipData — The ZIP archive as ArrayBuffer, Uint8Array, or Blob
|
|
80
|
-
* @returns A MemoryContentContainer populated with the ZIP's contents
|
|
81
|
-
*/
|
|
82
|
-
export async function zipToContainer(
|
|
83
|
-
zipData: ArrayBuffer | Uint8Array | Blob,
|
|
84
|
-
options: ZipToContainerOptions = {},
|
|
85
|
-
): Promise<MemoryContentContainer> {
|
|
86
|
-
const archive = await openBoundedZipArchive(zipData, options);
|
|
87
|
-
const container = new MemoryContentContainer();
|
|
88
|
-
for (const { path } of archive.entries) {
|
|
89
|
-
const data = await archive.read(path);
|
|
90
|
-
if (data) await container.writeFile(path, data);
|
|
91
|
-
archive.release(path);
|
|
92
|
-
}
|
|
93
|
-
return container;
|
|
94
|
-
}
|
package/src/csv/index.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
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
|
-
}
|