@bendyline/squisq-formats 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +180 -43
  2. package/dist/{chunk-33YRFXZZ.js → chunk-46FMDOWZ.js} +260 -14
  3. package/dist/chunk-46FMDOWZ.js.map +1 -0
  4. package/dist/{chunk-ERZ627GR.js → chunk-5LSSWZPU.js} +12 -27
  5. package/dist/chunk-5LSSWZPU.js.map +1 -0
  6. package/dist/chunk-6M7Z25LA.js +46 -0
  7. package/dist/chunk-6M7Z25LA.js.map +1 -0
  8. package/dist/{chunk-S3Y7H2BK.js → chunk-D7IFPWNZ.js} +95 -50
  9. package/dist/chunk-D7IFPWNZ.js.map +1 -0
  10. package/dist/chunk-DQAZR57U.js +33 -0
  11. package/dist/chunk-DQAZR57U.js.map +1 -0
  12. package/dist/chunk-EHLGMSTE.js +273 -0
  13. package/dist/chunk-EHLGMSTE.js.map +1 -0
  14. package/dist/{chunk-A3FHLTY5.js → chunk-MJGRI6XR.js} +6 -19
  15. package/dist/chunk-MJGRI6XR.js.map +1 -0
  16. package/dist/chunk-PN52A5AA.js +32 -0
  17. package/dist/chunk-PN52A5AA.js.map +1 -0
  18. package/dist/chunk-RFAPOKHJ.js +123 -0
  19. package/dist/chunk-RFAPOKHJ.js.map +1 -0
  20. package/dist/chunk-RTS5XBZ7.js +526 -0
  21. package/dist/chunk-RTS5XBZ7.js.map +1 -0
  22. package/dist/{chunk-7DEUGIOO.js → chunk-SHLPSADL.js} +120 -2
  23. package/dist/chunk-SHLPSADL.js.map +1 -0
  24. package/dist/chunk-SSUPBUF5.js +15 -0
  25. package/dist/chunk-SSUPBUF5.js.map +1 -0
  26. package/dist/{chunk-UDS45KUJ.js → chunk-WWBIKRNP.js} +40 -80
  27. package/dist/chunk-WWBIKRNP.js.map +1 -0
  28. package/dist/{chunk-VN2KEOYB.js → chunk-YESS7VY3.js} +379 -87
  29. package/dist/chunk-YESS7VY3.js.map +1 -0
  30. package/dist/csv/index.d.ts +50 -0
  31. package/dist/csv/index.js +13 -0
  32. package/dist/csv/index.js.map +1 -0
  33. package/dist/docx/index.js +6 -3
  34. package/dist/epub/index.js +4 -2
  35. package/dist/html/index.d.ts +42 -2
  36. package/dist/html/index.js +9 -2
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +47 -14
  39. package/dist/ooxml/index.d.ts +5 -1
  40. package/dist/ooxml/index.js +13 -9
  41. package/dist/pdf/index.js +2 -1
  42. package/dist/pptx/index.d.ts +36 -19
  43. package/dist/pptx/index.js +9 -2
  44. package/dist/registry/index.d.ts +167 -0
  45. package/dist/registry/index.js +17 -0
  46. package/dist/registry/index.js.map +1 -0
  47. package/dist/xlsx/index.d.ts +60 -29
  48. package/dist/xlsx/index.js +4 -1
  49. package/package.json +12 -2
  50. package/src/__tests__/convert.test.ts +186 -0
  51. package/src/__tests__/csvImport.test.ts +84 -0
  52. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  53. package/src/__tests__/formatRegistry.test.ts +174 -0
  54. package/src/__tests__/htmlImport.test.ts +57 -0
  55. package/src/__tests__/lossyWarnings.test.ts +105 -0
  56. package/src/__tests__/pdfImport.test.ts +95 -1
  57. package/src/__tests__/plainHtml.test.ts +31 -0
  58. package/src/__tests__/pptxExport.test.ts +138 -0
  59. package/src/__tests__/pptxImport.test.ts +115 -0
  60. package/src/__tests__/roundTripAssets.test.ts +50 -0
  61. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  62. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  63. package/src/__tests__/roundTripMatrix.test.ts +142 -0
  64. package/src/__tests__/sharedContainer.test.ts +41 -0
  65. package/src/__tests__/sharedImages.test.ts +61 -0
  66. package/src/__tests__/xlsxExport.test.ts +164 -0
  67. package/src/__tests__/xlsxImport.test.ts +80 -0
  68. package/src/csv/index.ts +188 -0
  69. package/src/docx/export.ts +27 -44
  70. package/src/docx/import.ts +7 -39
  71. package/src/epub/export.ts +10 -27
  72. package/src/html/imageUtils.ts +5 -19
  73. package/src/html/import.ts +297 -0
  74. package/src/html/index.ts +4 -0
  75. package/src/html/plainHtml.ts +66 -16
  76. package/src/index.ts +36 -5
  77. package/src/ooxml/index.ts +3 -0
  78. package/src/ooxml/namespaces.ts +13 -0
  79. package/src/pdf/export.ts +10 -5
  80. package/src/pdf/import.ts +146 -51
  81. package/src/pptx/export.ts +225 -85
  82. package/src/pptx/import.ts +273 -0
  83. package/src/pptx/index.ts +10 -28
  84. package/src/registry/convert.ts +318 -0
  85. package/src/registry/defaultFormats.ts +323 -0
  86. package/src/registry/errors.ts +46 -0
  87. package/src/registry/index.ts +38 -0
  88. package/src/registry/registry.ts +48 -0
  89. package/src/registry/types.ts +107 -0
  90. package/src/shared/container.ts +28 -0
  91. package/src/shared/images.ts +44 -0
  92. package/src/shared/inlineRuns.ts +99 -0
  93. package/src/shared/text.ts +41 -0
  94. package/src/xlsx/export.ts +253 -0
  95. package/src/xlsx/import.ts +159 -0
  96. package/src/xlsx/index.ts +18 -61
  97. package/dist/chunk-33YRFXZZ.js.map +0 -1
  98. package/dist/chunk-67KIJHV2.js +0 -21
  99. package/dist/chunk-67KIJHV2.js.map +0 -1
  100. package/dist/chunk-7DEUGIOO.js.map +0 -1
  101. package/dist/chunk-A3FHLTY5.js.map +0 -1
  102. package/dist/chunk-ERZ627GR.js.map +0 -1
  103. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  104. package/dist/chunk-UDS45KUJ.js.map +0 -1
  105. package/dist/chunk-VN2KEOYB.js.map +0 -1
  106. package/dist/chunk-YN5HFCEW.js +0 -120
  107. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { MarkdownBlockNode, MarkdownDocument } from '@bendyline/squisq/markdown';
3
+ import type { Transition } from '@bendyline/squisq/schemas';
4
+ import { TRANSITION_TYPES } from '@bendyline/squisq/schemas';
5
+ import { markdownDocToPptx } from '../pptx/export';
6
+ import { openPackage, getPartXml } from '../ooxml/reader';
7
+ import { NS_PML, NS_PML_2010 } from '../ooxml/namespaces';
8
+
9
+ function docWithSecondSlideTransition(transition: Transition): MarkdownDocument {
10
+ return {
11
+ type: 'document',
12
+ children: [
13
+ {
14
+ type: 'heading',
15
+ depth: 2,
16
+ children: [{ type: 'text', value: 'One' }],
17
+ },
18
+ {
19
+ type: 'paragraph',
20
+ children: [{ type: 'text', value: 'Intro' }],
21
+ },
22
+ {
23
+ type: 'heading',
24
+ depth: 2,
25
+ children: [{ type: 'text', value: 'Two' }],
26
+ attributes: {
27
+ params: {
28
+ transition: transition.type,
29
+ ...(transition.duration !== undefined
30
+ ? { transitionDuration: String(transition.duration) }
31
+ : {}),
32
+ ...(transition.direction ? { transitionDirection: transition.direction } : {}),
33
+ },
34
+ blockMeta: { transition },
35
+ },
36
+ },
37
+ {
38
+ type: 'paragraph',
39
+ children: [{ type: 'text', value: 'Body' }],
40
+ },
41
+ ],
42
+ };
43
+ }
44
+
45
+ function docWithAllTransitions(): MarkdownDocument {
46
+ const children: MarkdownBlockNode[] = [
47
+ {
48
+ type: 'heading',
49
+ depth: 2,
50
+ children: [{ type: 'text', value: 'Transition Gallery' }],
51
+ },
52
+ {
53
+ type: 'paragraph',
54
+ children: [{ type: 'text', value: 'Intro slide before transition checks.' }],
55
+ },
56
+ ];
57
+
58
+ for (const type of TRANSITION_TYPES) {
59
+ const transition: Transition = { type, duration: 0.8 };
60
+ children.push(
61
+ {
62
+ type: 'heading',
63
+ depth: 2,
64
+ children: [{ type: 'text', value: type }],
65
+ attributes: {
66
+ params: { transition: type, transitionDuration: '0.8' },
67
+ blockMeta: { transition },
68
+ },
69
+ },
70
+ {
71
+ type: 'paragraph',
72
+ children: [{ type: 'text', value: `Transition token: ${type}` }],
73
+ },
74
+ );
75
+ }
76
+
77
+ return { type: 'document', children };
78
+ }
79
+
80
+ describe('markdownDocToPptx transitions', () => {
81
+ it('writes valid transition XML for every shared transition token', async () => {
82
+ const pkg = await openPackage(await markdownDocToPptx(docWithAllTransitions()));
83
+
84
+ for (let i = 0; i < TRANSITION_TYPES.length; i++) {
85
+ const type = TRANSITION_TYPES[i];
86
+ const slide = await getPartXml(pkg, `ppt/slides/slide${i + 2}.xml`);
87
+ expect(slide, type).not.toBeNull();
88
+
89
+ const transitions = slide!.getElementsByTagNameNS(NS_PML, 'transition');
90
+ if (type === 'cut') {
91
+ expect(transitions, type).toHaveLength(0);
92
+ continue;
93
+ }
94
+
95
+ expect(transitions, type).toHaveLength(1);
96
+ expect(transitions[0].getAttribute('spd'), type).toBe('med');
97
+
98
+ const childElements = Array.from(transitions[0].childNodes).filter(
99
+ (node): node is Element => node.nodeType === 1,
100
+ );
101
+ expect(childElements, type).toHaveLength(1);
102
+ expect([NS_PML, NS_PML_2010], type).toContain(childElements[0].namespaceURI);
103
+
104
+ if (childElements[0].namespaceURI === NS_PML_2010) {
105
+ expect(slide!.documentElement.getAttribute('mc:Ignorable'), type).toContain('p14');
106
+ }
107
+ }
108
+ });
109
+
110
+ it('writes standard PresentationML transition XML', async () => {
111
+ const md = docWithSecondSlideTransition({
112
+ type: 'checkerboard',
113
+ duration: 1.3,
114
+ direction: 'vertical',
115
+ });
116
+
117
+ const pkg = await openPackage(await markdownDocToPptx(md));
118
+ const slide = await getPartXml(pkg, 'ppt/slides/slide2.xml');
119
+ const transition = slide!.getElementsByTagNameNS(NS_PML, 'transition')[0];
120
+ const checker = transition.getElementsByTagNameNS(NS_PML, 'checker')[0];
121
+
122
+ expect(transition.getAttribute('spd')).toBe('slow');
123
+ expect(checker.getAttribute('dir')).toBe('vert');
124
+ });
125
+
126
+ it('writes PowerPoint 2010 transition XML when needed', async () => {
127
+ const md = docWithSecondSlideTransition({ type: 'flash', duration: 0.4 });
128
+
129
+ const pkg = await openPackage(await markdownDocToPptx(md));
130
+ const slide = await getPartXml(pkg, 'ppt/slides/slide2.xml');
131
+ const transition = slide!.getElementsByTagNameNS(NS_PML, 'transition')[0];
132
+ const flash = transition.getElementsByTagNameNS(NS_PML_2010, 'flash')[0];
133
+
134
+ expect(transition.getAttribute('spd')).toBe('fast');
135
+ expect(flash).toBeDefined();
136
+ expect(slide!.documentElement.getAttribute('mc:Ignorable')).toBe('p14');
137
+ });
138
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tests for PPTX import: pptxToMarkdownDoc. Builds a minimal .pptx fixture with
3
+ * the shared OOXML writer, then imports it.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import type {
8
+ MarkdownDocument,
9
+ MarkdownHeading,
10
+ MarkdownList,
11
+ MarkdownParagraph,
12
+ MarkdownText,
13
+ } from '@bendyline/squisq/markdown';
14
+ import { NS_DRAWINGML, NS_PML, NS_R, REL_OFFICE_DOCUMENT, REL_SLIDE } from '../ooxml/namespaces';
15
+ import { createPackage } from '../ooxml/writer';
16
+ import { xmlDeclaration } from '../ooxml/xmlUtils';
17
+ import { markdownDocToPptx } from '../pptx/export';
18
+ import { pptxToContainer, pptxToMarkdownDoc } from '../pptx/import';
19
+
20
+ /** Distinctive PNG-signature-prefixed payload so round-trip fidelity is checkable. */
21
+ const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4, 5]);
22
+
23
+ async function buildTestPptx(): Promise<ArrayBuffer> {
24
+ const pkg = createPackage();
25
+
26
+ pkg.addPart(
27
+ 'ppt/presentation.xml',
28
+ `${xmlDeclaration()}<p:presentation xmlns:p="${NS_PML}" xmlns:r="${NS_R}">` +
29
+ `<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`,
30
+ 'application/xml',
31
+ );
32
+ pkg.addPart(
33
+ 'ppt/slides/slide1.xml',
34
+ `${xmlDeclaration()}<p:sld xmlns:p="${NS_PML}" xmlns:a="${NS_DRAWINGML}"><p:cSld><p:spTree>` +
35
+ `<p:sp><p:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>` +
36
+ `<p:txBody><a:p><a:r><a:t>My Title</a:t></a:r></a:p></p:txBody></p:sp>` +
37
+ `<p:sp><p:txBody>` +
38
+ `<a:p><a:r><a:t>First bullet</a:t></a:r></a:p>` +
39
+ `<a:p><a:r><a:t>Second bullet</a:t></a:r></a:p>` +
40
+ `</p:txBody></p:sp>` +
41
+ `</p:spTree></p:cSld></p:sld>`,
42
+ 'application/xml',
43
+ );
44
+
45
+ pkg.addRelationship('', {
46
+ id: 'rId1',
47
+ type: REL_OFFICE_DOCUMENT,
48
+ target: 'ppt/presentation.xml',
49
+ });
50
+ pkg.addRelationship('ppt/presentation.xml', {
51
+ id: 'rId1',
52
+ type: REL_SLIDE,
53
+ target: 'slides/slide1.xml',
54
+ });
55
+
56
+ return pkg.toArrayBuffer();
57
+ }
58
+
59
+ describe('pptxToMarkdownDoc', () => {
60
+ it('imports a slide as a heading + bullet list', async () => {
61
+ const doc = await pptxToMarkdownDoc(await buildTestPptx());
62
+ expect(doc.type).toBe('document');
63
+
64
+ const heading = doc.children[0] as MarkdownHeading;
65
+ expect(heading.type).toBe('heading');
66
+ expect(heading.depth).toBe(2);
67
+ expect((heading.children[0] as MarkdownText).value).toBe('My Title');
68
+
69
+ const list = doc.children[1] as MarkdownList;
70
+ expect(list.type).toBe('list');
71
+ expect(list.children).toHaveLength(2);
72
+ const firstItemPara = list.children[0]!.children[0] as MarkdownParagraph;
73
+ expect((firstItemPara.children[0] as MarkdownText).value).toBe('First bullet');
74
+ });
75
+ });
76
+
77
+ describe('pptxToContainer (embedded image import)', () => {
78
+ const deckWithImage: MarkdownDocument = {
79
+ type: 'document',
80
+ children: [
81
+ { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Picture Slide' }] },
82
+ { type: 'paragraph', children: [{ type: 'image', url: 'photo.png', alt: 'A photo' }] },
83
+ ],
84
+ };
85
+
86
+ async function buildDeck(): Promise<ArrayBuffer> {
87
+ return markdownDocToPptx(deckWithImage, {
88
+ images: new Map([['photo.png', IMAGE_BYTES.buffer]]),
89
+ });
90
+ }
91
+
92
+ it('extracts embedded images into the container and references them', async () => {
93
+ const container = await pptxToContainer(await buildDeck());
94
+
95
+ // The extracted image exists at the docx-style images/imageN.ext path.
96
+ expect(await container.exists('images/image1.png')).toBe(true);
97
+
98
+ // The bytes survived the round-trip verbatim.
99
+ const bytes = await container.readFile('images/image1.png');
100
+ expect(bytes).not.toBeNull();
101
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
102
+
103
+ // The markdown references the extracted image (no dangling ref).
104
+ const markdown = await container.readDocument();
105
+ expect(markdown).toContain('images/image1.png');
106
+ });
107
+
108
+ it('does not emit dangling image refs without extractImages', async () => {
109
+ const doc = await pptxToMarkdownDoc(await buildDeck());
110
+ const hasImage = doc.children.some(
111
+ (b) => b.type === 'paragraph' && b.children.some((c) => c.type === 'image'),
112
+ );
113
+ expect(hasImage).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Asset-fidelity guard: an embedded image must survive a full export → import
3
+ * round-trip for every container-producing format. We author a markdown doc
4
+ * with an image, export it (supplying the image bytes), re-import via the
5
+ * format's `*ToContainer`, and assert the bytes + reference come back intact.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
10
+ import { markdownDocToPptx } from '../pptx/export';
11
+ import { pptxToContainer } from '../pptx/import';
12
+ import { markdownDocToDocx } from '../docx/export';
13
+ import { docxToContainer } from '../docx/import';
14
+
15
+ /** PNG-signature-prefixed payload with a distinctive tail for byte comparison. */
16
+ const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9, 8, 7, 6]);
17
+
18
+ const doc: MarkdownDocument = {
19
+ type: 'document',
20
+ children: [
21
+ { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Assets' }] },
22
+ { type: 'paragraph', children: [{ type: 'image', url: 'hero.png', alt: 'Hero' }] },
23
+ ],
24
+ };
25
+
26
+ describe('embedded image round-trips through container formats', () => {
27
+ it('survives markdown → pptx → pptxToContainer', async () => {
28
+ const pptx = await markdownDocToPptx(doc, {
29
+ images: new Map([['hero.png', IMAGE_BYTES.buffer]]),
30
+ });
31
+ const container = await pptxToContainer(pptx);
32
+
33
+ expect(await container.exists('images/image1.png')).toBe(true);
34
+ const bytes = await container.readFile('images/image1.png');
35
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
36
+ expect(await container.readDocument()).toContain('images/image1.png');
37
+ });
38
+
39
+ it('survives markdown → docx → docxToContainer', async () => {
40
+ const docx = await markdownDocToDocx(doc, {
41
+ images: new Map([['hero.png', { data: IMAGE_BYTES.buffer, contentType: 'image/png' }]]),
42
+ });
43
+ const container = await docxToContainer(docx);
44
+
45
+ expect(await container.exists('images/image1.png')).toBe(true);
46
+ const bytes = await container.readFile('images/image1.png');
47
+ expect(new Uint8Array(bytes!)).toEqual(IMAGE_BYTES);
48
+ expect(await container.readDocument()).toContain('images/image1.png');
49
+ });
50
+ });
@@ -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,142 @@
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 { xlsxToMarkdownDoc } from '../xlsx/import';
16
+ import { assertHybridRoundTrip, extractNormalizedText } from './roundTripMatrix.helpers';
17
+ import { ROUNDTRIP_FIXTURES } from './roundTripMatrix.fixtures';
18
+
19
+ const MOCK_PLAYER_SCRIPT = 'var SquisqPlayer={mount:function(){}};';
20
+
21
+ describe('format content-flow matrix', () => {
22
+ it('round-trips markdown through DOCX with hybrid equivalence', async () => {
23
+ const source = ROUNDTRIP_FIXTURES.story;
24
+ const docx = await markdownDocToDocx(source.doc);
25
+ const roundTrip = await docxToMarkdownDoc(docx);
26
+
27
+ assertHybridRoundTrip({
28
+ source: source.doc,
29
+ roundTrip,
30
+ keyPhrases: source.keyPhrases,
31
+ headingTexts: source.headingTexts,
32
+ requireHeadingOrder: true,
33
+ requireListSurvival: true,
34
+ minRetainRatio: 0.65,
35
+ });
36
+ });
37
+
38
+ it('round-trips markdown through PPTX with hybrid equivalence', async () => {
39
+ const source = ROUNDTRIP_FIXTURES.mixed;
40
+ const pptx = await markdownDocToPptx(source.doc);
41
+ const roundTrip = await pptxToMarkdownDoc(pptx);
42
+
43
+ assertHybridRoundTrip({
44
+ source: source.doc,
45
+ roundTrip,
46
+ keyPhrases: source.keyPhrases,
47
+ headingTexts: source.headingTexts,
48
+ requireHeadingOrder: true,
49
+ minRetainRatio: 0.5,
50
+ });
51
+ });
52
+
53
+ it('round-trips markdown through PDF with hybrid equivalence', async () => {
54
+ const source = ROUNDTRIP_FIXTURES.mixed;
55
+ const pdf = await markdownDocToPdf(source.doc, { title: 'Ops Weekly' });
56
+ const roundTrip = await pdfToMarkdownDoc(pdf, { bodyFontSize: 11 });
57
+
58
+ assertHybridRoundTrip({
59
+ source: source.doc,
60
+ roundTrip,
61
+ keyPhrases: ['ops weekly', 'launch readiness', 'rollback script', 'support tickets declined'],
62
+ minRetainRatio: 0.4,
63
+ });
64
+ });
65
+
66
+ it('round-trips markdown through CSV for table-centric content', async () => {
67
+ const source = ROUNDTRIP_FIXTURES.table;
68
+ const csv = markdownDocToCsv(source.doc);
69
+ const roundTrip = await csvToMarkdownDoc(csv);
70
+
71
+ assertHybridRoundTrip({
72
+ source: source.doc,
73
+ roundTrip,
74
+ keyPhrases: ['throughput', '120 req/s', 'error rate', 'p95'],
75
+ requireTableSurvival: true,
76
+ minRetainRatio: 0.7,
77
+ });
78
+ });
79
+
80
+ it('exports markdown to HTML as one-way smoke coverage', async () => {
81
+ const source = ROUNDTRIP_FIXTURES.story;
82
+ const doc = markdownToDoc(source.doc, { articleId: 'matrix-story' });
83
+ const html = docToHtml(doc, {
84
+ playerScript: MOCK_PLAYER_SCRIPT,
85
+ title: 'Matrix Story',
86
+ });
87
+
88
+ expect(html).toContain('<!DOCTYPE html>');
89
+ const normalizedHtml = html.toLowerCase();
90
+ expect(normalizedHtml).toContain('river journal');
91
+ expect(normalizedHtml).toContain('north bank');
92
+ expect(normalizedHtml).toContain('full report');
93
+ });
94
+
95
+ it('exports markdown to EPUB as one-way smoke coverage', async () => {
96
+ const source = ROUNDTRIP_FIXTURES.story;
97
+ const epub = await markdownDocToEpub(source.doc, {
98
+ title: 'River Journal',
99
+ author: 'Squisq Test',
100
+ });
101
+
102
+ const zip = await JSZip.loadAsync(epub);
103
+ expect(zip.file('OEBPS/content.opf')).toBeTruthy();
104
+
105
+ const chapter = await zip.file('OEBPS/chapters/chapter-001.xhtml')?.async('string');
106
+ expect(chapter).toBeTruthy();
107
+
108
+ const normalized = (chapter ?? '').toLowerCase();
109
+ expect(normalized).toContain('river journal');
110
+ expect(normalized).toContain('north bank');
111
+ });
112
+
113
+ it('keeps high-value text through all bidirectional formats', async () => {
114
+ const source = ROUNDTRIP_FIXTURES.story;
115
+
116
+ const docxRoundTrip = await docxToMarkdownDoc(await markdownDocToDocx(source.doc));
117
+ const pptxRoundTrip = await pptxToMarkdownDoc(await markdownDocToPptx(source.doc));
118
+ const pdfRoundTrip = await pdfToMarkdownDoc(await markdownDocToPdf(source.doc));
119
+ const csvRoundTrip = await csvToMarkdownDoc(markdownDocToCsv(ROUNDTRIP_FIXTURES.table.doc));
120
+
121
+ const docxText = extractNormalizedText(docxRoundTrip);
122
+ const pptxText = extractNormalizedText(pptxRoundTrip);
123
+ const pdfText = extractNormalizedText(pdfRoundTrip);
124
+ const csvText = extractNormalizedText(csvRoundTrip);
125
+
126
+ expect(docxText).toContain('river journal');
127
+ expect(pptxText).toContain('river journal');
128
+ expect(pdfText).toContain('river journal');
129
+ expect(csvText).toContain('throughput');
130
+ });
131
+
132
+ it('round-trips markdown through XLSX with table cell survival', async () => {
133
+ const source = ROUNDTRIP_FIXTURES.table;
134
+ const xlsx = await markdownDocToXlsx(source.doc);
135
+ const roundTrip = await xlsxToMarkdownDoc(xlsx);
136
+
137
+ const text = extractNormalizedText(roundTrip);
138
+ for (const phrase of source.keyPhrases) {
139
+ expect(text).toContain(phrase);
140
+ }
141
+ });
142
+ });