@bendyline/squisq-formats 1.3.1 → 1.4.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/dist/chunk-6M7Z25LA.js +46 -0
- package/dist/chunk-6M7Z25LA.js.map +1 -0
- package/dist/{chunk-S3Y7H2BK.js → chunk-APSIAEXL.js} +9 -8
- package/dist/chunk-APSIAEXL.js.map +1 -0
- package/dist/{chunk-UDS45KUJ.js → chunk-BJQIJ4S4.js} +36 -52
- package/dist/chunk-BJQIJ4S4.js.map +1 -0
- package/dist/{chunk-33YRFXZZ.js → chunk-CRTC6DEX.js} +259 -13
- package/dist/chunk-CRTC6DEX.js.map +1 -0
- package/dist/chunk-FMOIGA5T.js +201 -0
- package/dist/chunk-FMOIGA5T.js.map +1 -0
- package/dist/{chunk-VN2KEOYB.js → chunk-I2GFTEOV.js} +321 -91
- package/dist/chunk-I2GFTEOV.js.map +1 -0
- package/dist/chunk-JBNVE2GV.js +116 -0
- package/dist/chunk-JBNVE2GV.js.map +1 -0
- package/dist/chunk-L53YIGYS.js +141 -0
- package/dist/chunk-L53YIGYS.js.map +1 -0
- package/dist/{chunk-ERZ627GR.js → chunk-ON232HJR.js} +11 -26
- package/dist/chunk-ON232HJR.js.map +1 -0
- package/dist/chunk-PN52A5AA.js +32 -0
- package/dist/chunk-PN52A5AA.js.map +1 -0
- package/dist/{chunk-7DEUGIOO.js → chunk-QSE7EWE7.js} +12 -87
- package/dist/chunk-QSE7EWE7.js.map +1 -0
- package/dist/csv/index.d.ts +37 -0
- package/dist/csv/index.js +13 -0
- package/dist/csv/index.js.map +1 -0
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.js +2 -1
- package/dist/html/index.d.ts +39 -2
- package/dist/html/index.js +7 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +31 -13
- package/dist/ooxml/index.js +16 -16
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +20 -18
- package/dist/pptx/index.js +6 -2
- package/dist/xlsx/index.d.ts +20 -19
- package/dist/xlsx/index.js +2 -1
- package/package.json +7 -2
- package/src/__tests__/csvImport.test.ts +51 -0
- package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
- package/src/__tests__/htmlImport.test.ts +57 -0
- package/src/__tests__/plainHtml.test.ts +31 -0
- package/src/__tests__/pptxExport.test.ts +138 -0
- package/src/__tests__/pptxImport.test.ts +70 -0
- package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
- package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
- package/src/__tests__/roundTripMatrix.test.ts +136 -0
- package/src/__tests__/xlsxImport.test.ts +80 -0
- package/src/csv/index.ts +165 -0
- package/src/docx/export.ts +27 -44
- package/src/docx/import.ts +1 -2
- package/src/epub/export.ts +10 -27
- 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 +10 -3
- package/src/ooxml/namespaces.ts +3 -0
- package/src/pdf/export.ts +10 -5
- package/src/pdf/import.ts +1 -1
- package/src/pptx/export.ts +225 -85
- package/src/pptx/import.ts +156 -0
- package/src/pptx/index.ts +10 -28
- package/src/shared/inlineRuns.ts +99 -0
- package/src/shared/text.ts +41 -0
- package/src/xlsx/import.ts +159 -0
- package/src/xlsx/index.ts +11 -28
- 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-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,86 @@
|
|
|
1
|
+
import { parseMarkdown, type MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
2
|
+
|
|
3
|
+
export interface RoundTripFixture {
|
|
4
|
+
name: string;
|
|
5
|
+
markdown: string;
|
|
6
|
+
doc: MarkdownDocument;
|
|
7
|
+
keyPhrases: string[];
|
|
8
|
+
headingTexts: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeFixture(
|
|
12
|
+
name: string,
|
|
13
|
+
markdown: string,
|
|
14
|
+
keyPhrases: string[],
|
|
15
|
+
headingTexts: string[],
|
|
16
|
+
): RoundTripFixture {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
markdown,
|
|
20
|
+
doc: parseMarkdown(markdown),
|
|
21
|
+
keyPhrases,
|
|
22
|
+
headingTexts,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ROUNDTRIP_FIXTURES = {
|
|
27
|
+
story: makeFixture(
|
|
28
|
+
'story',
|
|
29
|
+
`# River Journal
|
|
30
|
+
|
|
31
|
+
The field team walked the north bank and recorded water conditions.
|
|
32
|
+
|
|
33
|
+
## Morning Snapshot
|
|
34
|
+
|
|
35
|
+
- Water level is steady.
|
|
36
|
+
- Team status is ready.
|
|
37
|
+
- Safety checks are complete.
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
The crew published an update and linked the [full report](https://example.com/report).
|
|
42
|
+
`,
|
|
43
|
+
['river journal', 'north bank', 'water level', 'team status', 'full report'],
|
|
44
|
+
['River Journal', 'Morning Snapshot', 'Notes'],
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
mixed: makeFixture(
|
|
48
|
+
'mixed',
|
|
49
|
+
`# Ops Weekly
|
|
50
|
+
|
|
51
|
+
This week we tracked **launch readiness**, *quality signals*, and risk burn-down.
|
|
52
|
+
|
|
53
|
+
## Checklist
|
|
54
|
+
|
|
55
|
+
1. Confirm deployment window.
|
|
56
|
+
2. Validate rollback script.
|
|
57
|
+
3. Notify stakeholders.
|
|
58
|
+
|
|
59
|
+
## Outcome
|
|
60
|
+
|
|
61
|
+
Launch readiness improved and support tickets declined.
|
|
62
|
+
`,
|
|
63
|
+
[
|
|
64
|
+
'ops weekly',
|
|
65
|
+
'launch readiness',
|
|
66
|
+
'quality signals',
|
|
67
|
+
'rollback script',
|
|
68
|
+
'support tickets declined',
|
|
69
|
+
],
|
|
70
|
+
['Ops Weekly', 'Checklist', 'Outcome'],
|
|
71
|
+
),
|
|
72
|
+
|
|
73
|
+
table: makeFixture(
|
|
74
|
+
'table',
|
|
75
|
+
`# Metrics
|
|
76
|
+
|
|
77
|
+
| Metric | Value |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| Throughput | 120 req/s |
|
|
80
|
+
| Error Rate | 0.2% |
|
|
81
|
+
| P95 | 180ms |
|
|
82
|
+
`,
|
|
83
|
+
['metrics', 'throughput', '120 req/s', 'error rate', 'p95'],
|
|
84
|
+
['Metrics'],
|
|
85
|
+
),
|
|
86
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { expect } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
MarkdownDocument,
|
|
4
|
+
MarkdownNode,
|
|
5
|
+
MarkdownBlockNode,
|
|
6
|
+
MarkdownHeading,
|
|
7
|
+
} from '@bendyline/squisq/markdown';
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function collectText(node: unknown, out: string[]): void {
|
|
14
|
+
if (!isRecord(node)) return;
|
|
15
|
+
|
|
16
|
+
const maybeType = node.type;
|
|
17
|
+
if (typeof maybeType === 'string') {
|
|
18
|
+
if (maybeType === 'text' && typeof node.value === 'string') {
|
|
19
|
+
out.push(node.value);
|
|
20
|
+
}
|
|
21
|
+
if ((maybeType === 'code' || maybeType === 'inlineCode') && typeof node.value === 'string') {
|
|
22
|
+
out.push(node.value);
|
|
23
|
+
}
|
|
24
|
+
if (maybeType === 'link' && typeof node.url === 'string') {
|
|
25
|
+
out.push(node.url);
|
|
26
|
+
}
|
|
27
|
+
if (maybeType === 'image') {
|
|
28
|
+
if (typeof node.alt === 'string') out.push(node.alt);
|
|
29
|
+
if (typeof node.url === 'string') out.push(node.url);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const children = node.children;
|
|
34
|
+
if (Array.isArray(children)) {
|
|
35
|
+
for (const child of children) collectText(child, out);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeText(input: string): string {
|
|
40
|
+
return input
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/\r\n/g, '\n')
|
|
43
|
+
.replace(/[^a-z0-9%/.:\-\s]+/g, ' ')
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function compactText(input: string): string {
|
|
49
|
+
return normalizeText(input).replace(/\s+/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractDocumentText(doc: MarkdownDocument): string {
|
|
53
|
+
const out: string[] = [];
|
|
54
|
+
collectText(doc, out);
|
|
55
|
+
return normalizeText(out.join(' '));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractHeadingTexts(doc: MarkdownDocument): string[] {
|
|
59
|
+
return doc.children
|
|
60
|
+
.filter((block): block is MarkdownHeading => block.type === 'heading')
|
|
61
|
+
.map((heading) => {
|
|
62
|
+
const out: string[] = [];
|
|
63
|
+
collectText(heading, out);
|
|
64
|
+
return normalizeText(out.join(' '));
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function containsNodeType(node: unknown, wantedType: string): boolean {
|
|
70
|
+
if (!isRecord(node)) return false;
|
|
71
|
+
if (node.type === wantedType) return true;
|
|
72
|
+
const children = node.children;
|
|
73
|
+
if (!Array.isArray(children)) return false;
|
|
74
|
+
return children.some((child) => containsNodeType(child, wantedType));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function retainRatio(source: string, roundTrip: string): number {
|
|
78
|
+
const sourceTokens = source.split(' ').filter((t) => t.length >= 4);
|
|
79
|
+
if (sourceTokens.length === 0) return 1;
|
|
80
|
+
|
|
81
|
+
const targetSet = new Set(roundTrip.split(' ').filter((t) => t.length >= 4));
|
|
82
|
+
let matched = 0;
|
|
83
|
+
for (const token of sourceTokens) {
|
|
84
|
+
if (targetSet.has(token)) matched++;
|
|
85
|
+
}
|
|
86
|
+
return matched / sourceTokens.length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HybridRoundTripAssertions {
|
|
90
|
+
source: MarkdownDocument;
|
|
91
|
+
roundTrip: MarkdownDocument;
|
|
92
|
+
keyPhrases: string[];
|
|
93
|
+
headingTexts?: string[];
|
|
94
|
+
requireHeadingOrder?: boolean;
|
|
95
|
+
requireListSurvival?: boolean;
|
|
96
|
+
requireTableSurvival?: boolean;
|
|
97
|
+
minRetainRatio?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function assertHybridRoundTrip(args: HybridRoundTripAssertions): void {
|
|
101
|
+
const sourceText = extractDocumentText(args.source);
|
|
102
|
+
const roundTripText = extractDocumentText(args.roundTrip);
|
|
103
|
+
|
|
104
|
+
expect(roundTripText.length).toBeGreaterThan(0);
|
|
105
|
+
|
|
106
|
+
const minRetainRatio = args.minRetainRatio ?? 0.5;
|
|
107
|
+
expect(retainRatio(sourceText, roundTripText)).toBeGreaterThanOrEqual(minRetainRatio);
|
|
108
|
+
|
|
109
|
+
for (const phrase of args.keyPhrases) {
|
|
110
|
+
const normalizedPhrase = normalizeText(phrase);
|
|
111
|
+
if (roundTripText.includes(normalizedPhrase)) continue;
|
|
112
|
+
expect(compactText(roundTripText)).toContain(compactText(normalizedPhrase));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.requireHeadingOrder && args.headingTexts && args.headingTexts.length > 0) {
|
|
116
|
+
const normalizedExpected = args.headingTexts.map((h) => normalizeText(h));
|
|
117
|
+
const actualHeadings = extractHeadingTexts(args.roundTrip);
|
|
118
|
+
|
|
119
|
+
let cursor = -1;
|
|
120
|
+
for (const expectedHeading of normalizedExpected) {
|
|
121
|
+
const next = actualHeadings.findIndex(
|
|
122
|
+
(heading, index) => index > cursor && heading.includes(expectedHeading),
|
|
123
|
+
);
|
|
124
|
+
expect(next).toBeGreaterThan(cursor);
|
|
125
|
+
cursor = next;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (args.requireListSurvival && containsNodeType(args.source, 'list')) {
|
|
130
|
+
expect(containsNodeType(args.roundTrip, 'list')).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (args.requireTableSurvival && containsNodeType(args.source, 'table')) {
|
|
134
|
+
expect(containsNodeType(args.roundTrip, 'table')).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function extractNormalizedText(doc: MarkdownDocument): string {
|
|
139
|
+
return extractDocumentText(doc);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function docHasNodeType(doc: MarkdownDocument, wantedType: MarkdownNode['type']): boolean {
|
|
143
|
+
return containsNodeType(doc, wantedType);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function firstHeadingText(doc: MarkdownDocument): string | null {
|
|
147
|
+
const firstHeading = doc.children.find(
|
|
148
|
+
(block): block is MarkdownBlockNode & { type: 'heading' } => block.type === 'heading',
|
|
149
|
+
);
|
|
150
|
+
if (!firstHeading) return null;
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
collectText(firstHeading, out);
|
|
153
|
+
return normalizeText(out.join(' '));
|
|
154
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import JSZip from 'jszip';
|
|
3
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
4
|
+
|
|
5
|
+
import { markdownDocToDocx } from '../docx/export';
|
|
6
|
+
import { docxToMarkdownDoc } from '../docx/import';
|
|
7
|
+
import { markdownDocToPptx } from '../pptx/export';
|
|
8
|
+
import { pptxToMarkdownDoc } from '../pptx/import';
|
|
9
|
+
import { markdownDocToPdf } from '../pdf/export';
|
|
10
|
+
import { pdfToMarkdownDoc } from '../pdf/import';
|
|
11
|
+
import { markdownDocToCsv, csvToMarkdownDoc } from '../csv/index';
|
|
12
|
+
import { markdownDocToEpub } from '../epub/export';
|
|
13
|
+
import { docToHtml } from '../html/index';
|
|
14
|
+
import { markdownDocToXlsx } from '../xlsx/index';
|
|
15
|
+
import { assertHybridRoundTrip, extractNormalizedText } from './roundTripMatrix.helpers';
|
|
16
|
+
import { ROUNDTRIP_FIXTURES } from './roundTripMatrix.fixtures';
|
|
17
|
+
|
|
18
|
+
const MOCK_PLAYER_SCRIPT = 'var SquisqPlayer={mount:function(){}};';
|
|
19
|
+
|
|
20
|
+
describe('format content-flow matrix', () => {
|
|
21
|
+
it('round-trips markdown through DOCX with hybrid equivalence', async () => {
|
|
22
|
+
const source = ROUNDTRIP_FIXTURES.story;
|
|
23
|
+
const docx = await markdownDocToDocx(source.doc);
|
|
24
|
+
const roundTrip = await docxToMarkdownDoc(docx);
|
|
25
|
+
|
|
26
|
+
assertHybridRoundTrip({
|
|
27
|
+
source: source.doc,
|
|
28
|
+
roundTrip,
|
|
29
|
+
keyPhrases: source.keyPhrases,
|
|
30
|
+
headingTexts: source.headingTexts,
|
|
31
|
+
requireHeadingOrder: true,
|
|
32
|
+
requireListSurvival: true,
|
|
33
|
+
minRetainRatio: 0.65,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('round-trips markdown through PPTX with hybrid equivalence', async () => {
|
|
38
|
+
const source = ROUNDTRIP_FIXTURES.mixed;
|
|
39
|
+
const pptx = await markdownDocToPptx(source.doc);
|
|
40
|
+
const roundTrip = await pptxToMarkdownDoc(pptx);
|
|
41
|
+
|
|
42
|
+
assertHybridRoundTrip({
|
|
43
|
+
source: source.doc,
|
|
44
|
+
roundTrip,
|
|
45
|
+
keyPhrases: source.keyPhrases,
|
|
46
|
+
headingTexts: source.headingTexts,
|
|
47
|
+
requireHeadingOrder: true,
|
|
48
|
+
minRetainRatio: 0.5,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('round-trips markdown through PDF with hybrid equivalence', async () => {
|
|
53
|
+
const source = ROUNDTRIP_FIXTURES.mixed;
|
|
54
|
+
const pdf = await markdownDocToPdf(source.doc, { title: 'Ops Weekly' });
|
|
55
|
+
const roundTrip = await pdfToMarkdownDoc(pdf, { bodyFontSize: 11 });
|
|
56
|
+
|
|
57
|
+
assertHybridRoundTrip({
|
|
58
|
+
source: source.doc,
|
|
59
|
+
roundTrip,
|
|
60
|
+
keyPhrases: ['ops weekly', 'launch readiness', 'rollback script', 'support tickets declined'],
|
|
61
|
+
minRetainRatio: 0.4,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('round-trips markdown through CSV for table-centric content', async () => {
|
|
66
|
+
const source = ROUNDTRIP_FIXTURES.table;
|
|
67
|
+
const csv = markdownDocToCsv(source.doc);
|
|
68
|
+
const roundTrip = await csvToMarkdownDoc(csv);
|
|
69
|
+
|
|
70
|
+
assertHybridRoundTrip({
|
|
71
|
+
source: source.doc,
|
|
72
|
+
roundTrip,
|
|
73
|
+
keyPhrases: ['throughput', '120 req/s', 'error rate', 'p95'],
|
|
74
|
+
requireTableSurvival: true,
|
|
75
|
+
minRetainRatio: 0.7,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('exports markdown to HTML as one-way smoke coverage', async () => {
|
|
80
|
+
const source = ROUNDTRIP_FIXTURES.story;
|
|
81
|
+
const doc = markdownToDoc(source.doc, { articleId: 'matrix-story' });
|
|
82
|
+
const html = docToHtml(doc, {
|
|
83
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
84
|
+
title: 'Matrix Story',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(html).toContain('<!DOCTYPE html>');
|
|
88
|
+
const normalizedHtml = html.toLowerCase();
|
|
89
|
+
expect(normalizedHtml).toContain('river journal');
|
|
90
|
+
expect(normalizedHtml).toContain('north bank');
|
|
91
|
+
expect(normalizedHtml).toContain('full report');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('exports markdown to EPUB as one-way smoke coverage', async () => {
|
|
95
|
+
const source = ROUNDTRIP_FIXTURES.story;
|
|
96
|
+
const epub = await markdownDocToEpub(source.doc, {
|
|
97
|
+
title: 'River Journal',
|
|
98
|
+
author: 'Squisq Test',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const zip = await JSZip.loadAsync(epub);
|
|
102
|
+
expect(zip.file('OEBPS/content.opf')).toBeTruthy();
|
|
103
|
+
|
|
104
|
+
const chapter = await zip.file('OEBPS/chapters/chapter-001.xhtml')?.async('string');
|
|
105
|
+
expect(chapter).toBeTruthy();
|
|
106
|
+
|
|
107
|
+
const normalized = (chapter ?? '').toLowerCase();
|
|
108
|
+
expect(normalized).toContain('river journal');
|
|
109
|
+
expect(normalized).toContain('north bank');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('keeps high-value text through all bidirectional formats', async () => {
|
|
113
|
+
const source = ROUNDTRIP_FIXTURES.story;
|
|
114
|
+
|
|
115
|
+
const docxRoundTrip = await docxToMarkdownDoc(await markdownDocToDocx(source.doc));
|
|
116
|
+
const pptxRoundTrip = await pptxToMarkdownDoc(await markdownDocToPptx(source.doc));
|
|
117
|
+
const pdfRoundTrip = await pdfToMarkdownDoc(await markdownDocToPdf(source.doc));
|
|
118
|
+
const csvRoundTrip = await csvToMarkdownDoc(markdownDocToCsv(ROUNDTRIP_FIXTURES.table.doc));
|
|
119
|
+
|
|
120
|
+
const docxText = extractNormalizedText(docxRoundTrip);
|
|
121
|
+
const pptxText = extractNormalizedText(pptxRoundTrip);
|
|
122
|
+
const pdfText = extractNormalizedText(pdfRoundTrip);
|
|
123
|
+
const csvText = extractNormalizedText(csvRoundTrip);
|
|
124
|
+
|
|
125
|
+
expect(docxText).toContain('river journal');
|
|
126
|
+
expect(pptxText).toContain('river journal');
|
|
127
|
+
expect(pdfText).toContain('river journal');
|
|
128
|
+
expect(csvText).toContain('throughput');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('asserts current XLSX export stub behavior', async () => {
|
|
132
|
+
await expect(markdownDocToXlsx(ROUNDTRIP_FIXTURES.table.doc)).rejects.toThrow(
|
|
133
|
+
'XLSX export is not yet implemented',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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,165 @@
|
|
|
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
|
+
|
|
36
|
+
async function toText(data: ArrayBuffer | Blob | string): Promise<string> {
|
|
37
|
+
if (typeof data === 'string') return data;
|
|
38
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) return data.text();
|
|
39
|
+
return new TextDecoder().decode(new Uint8Array(data as ArrayBuffer));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse CSV text into a grid of string cells (RFC 4180: quotes, escaped quotes). */
|
|
43
|
+
export function parseCsv(text: string, delimiter = ','): string[][] {
|
|
44
|
+
const rows: string[][] = [];
|
|
45
|
+
let row: string[] = [];
|
|
46
|
+
let field = '';
|
|
47
|
+
let inQuotes = false;
|
|
48
|
+
let sawAny = false;
|
|
49
|
+
const pushField = () => {
|
|
50
|
+
row.push(field);
|
|
51
|
+
field = '';
|
|
52
|
+
};
|
|
53
|
+
const pushRow = () => {
|
|
54
|
+
pushField();
|
|
55
|
+
rows.push(row);
|
|
56
|
+
row = [];
|
|
57
|
+
};
|
|
58
|
+
for (let i = 0; i < text.length; i++) {
|
|
59
|
+
const ch = text[i]!;
|
|
60
|
+
if (inQuotes) {
|
|
61
|
+
if (ch === '"') {
|
|
62
|
+
if (text[i + 1] === '"') {
|
|
63
|
+
field += '"';
|
|
64
|
+
i++;
|
|
65
|
+
} else {
|
|
66
|
+
inQuotes = false;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
field += ch;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === '"') {
|
|
74
|
+
inQuotes = true;
|
|
75
|
+
sawAny = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ch === delimiter) {
|
|
79
|
+
sawAny = true;
|
|
80
|
+
pushField();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '\n' || ch === '\r') {
|
|
84
|
+
if (ch === '\r' && text[i + 1] === '\n') i++;
|
|
85
|
+
pushRow();
|
|
86
|
+
sawAny = false;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
sawAny = true;
|
|
90
|
+
field += ch;
|
|
91
|
+
}
|
|
92
|
+
// Flush a trailing field/row only if the last line wasn't terminated.
|
|
93
|
+
if (field !== '' || row.length > 0 || sawAny) pushRow();
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rowsToTable(rows: string[][], hasHeader: boolean): MarkdownTable {
|
|
98
|
+
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 1);
|
|
99
|
+
const mdRows: MarkdownTableRow[] = rows.map((cells, rowIdx) => {
|
|
100
|
+
const children: MarkdownTableCell[] = [];
|
|
101
|
+
for (let c = 0; c < maxCols; c++) {
|
|
102
|
+
const value = cells[c] ?? '';
|
|
103
|
+
children.push({
|
|
104
|
+
type: 'tableCell',
|
|
105
|
+
...(hasHeader && rowIdx === 0 ? { isHeader: true } : {}),
|
|
106
|
+
children: value ? [{ type: 'text', value }] : [],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { type: 'tableRow', children };
|
|
110
|
+
});
|
|
111
|
+
return { type: 'table', children: mdRows };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Convert CSV to a MarkdownDocument containing a single table. */
|
|
115
|
+
export async function csvToMarkdownDoc(
|
|
116
|
+
data: ArrayBuffer | Blob | string,
|
|
117
|
+
options: CsvImportOptions = {},
|
|
118
|
+
): Promise<MarkdownDocument> {
|
|
119
|
+
const text = await toText(data);
|
|
120
|
+
const rows = parseCsv(text, options.delimiter ?? ',');
|
|
121
|
+
const hasHeader = options.hasHeader ?? true;
|
|
122
|
+
const children = rows.length > 0 ? [rowsToTable(rows, hasHeader)] : [];
|
|
123
|
+
return { type: 'document', children };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Convert CSV to a squisq Doc. */
|
|
127
|
+
export async function csvToDoc(
|
|
128
|
+
data: ArrayBuffer | Blob | string,
|
|
129
|
+
options: CsvImportOptions = {},
|
|
130
|
+
): Promise<Doc> {
|
|
131
|
+
return markdownToDoc(await csvToMarkdownDoc(data, options));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function escapeCsvField(value: string, delimiter: string): string {
|
|
135
|
+
if (value.includes('"') || value.includes(delimiter) || /[\r\n]/.test(value)) {
|
|
136
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cellText(cell: MarkdownTableCell): string {
|
|
142
|
+
// Flatten inline children to plain text (CSV has no formatting).
|
|
143
|
+
const walk = (nodes: unknown[]): string =>
|
|
144
|
+
nodes
|
|
145
|
+
.map((n) => {
|
|
146
|
+
const node = n as { type?: string; value?: string; children?: unknown[] };
|
|
147
|
+
if (node.value !== undefined) return node.value;
|
|
148
|
+
if (Array.isArray(node.children)) return walk(node.children);
|
|
149
|
+
return '';
|
|
150
|
+
})
|
|
151
|
+
.join('');
|
|
152
|
+
return walk(cell.children);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Serialize the first table in a MarkdownDocument to CSV text. */
|
|
156
|
+
export function markdownDocToCsv(doc: MarkdownDocument, options: CsvExportOptions = {}): string {
|
|
157
|
+
const delimiter = options.delimiter ?? ',';
|
|
158
|
+
const table = doc.children.find((n) => n.type === 'table') as MarkdownTable | undefined;
|
|
159
|
+
if (!table) return '';
|
|
160
|
+
return table.children
|
|
161
|
+
.map((row) =>
|
|
162
|
+
row.children.map((cell) => escapeCsvField(cellText(cell), delimiter)).join(delimiter),
|
|
163
|
+
)
|
|
164
|
+
.join('\r\n');
|
|
165
|
+
}
|