@bendyline/squisq-formats 1.3.0 → 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.
Files changed (224) hide show
  1. package/dist/chunk-6M7Z25LA.js +46 -0
  2. package/dist/chunk-6M7Z25LA.js.map +1 -0
  3. package/dist/{chunk-S3Y7H2BK.js → chunk-APSIAEXL.js} +9 -8
  4. package/dist/chunk-APSIAEXL.js.map +1 -0
  5. package/dist/{chunk-UDS45KUJ.js → chunk-BJQIJ4S4.js} +36 -52
  6. package/dist/chunk-BJQIJ4S4.js.map +1 -0
  7. package/dist/{chunk-33YRFXZZ.js → chunk-CRTC6DEX.js} +259 -13
  8. package/dist/chunk-CRTC6DEX.js.map +1 -0
  9. package/dist/chunk-FMOIGA5T.js +201 -0
  10. package/dist/chunk-FMOIGA5T.js.map +1 -0
  11. package/dist/{chunk-VN2KEOYB.js → chunk-I2GFTEOV.js} +321 -91
  12. package/dist/chunk-I2GFTEOV.js.map +1 -0
  13. package/dist/chunk-JBNVE2GV.js +116 -0
  14. package/dist/chunk-JBNVE2GV.js.map +1 -0
  15. package/dist/chunk-L53YIGYS.js +141 -0
  16. package/dist/chunk-L53YIGYS.js.map +1 -0
  17. package/dist/{chunk-ERZ627GR.js → chunk-ON232HJR.js} +11 -26
  18. package/dist/chunk-ON232HJR.js.map +1 -0
  19. package/dist/chunk-PN52A5AA.js +32 -0
  20. package/dist/chunk-PN52A5AA.js.map +1 -0
  21. package/dist/{chunk-7DEUGIOO.js → chunk-QSE7EWE7.js} +12 -87
  22. package/dist/chunk-QSE7EWE7.js.map +1 -0
  23. package/dist/container/index.d.ts +7 -5
  24. package/dist/container/index.js +39 -70
  25. package/dist/container/index.js.map +1 -1
  26. package/dist/csv/index.d.ts +37 -0
  27. package/dist/csv/index.js +13 -0
  28. package/dist/csv/index.js.map +1 -0
  29. package/dist/docx/index.d.ts +128 -15
  30. package/dist/docx/index.js +19 -22
  31. package/dist/docx/index.js.map +1 -1
  32. package/dist/epub/index.d.ts +66 -11
  33. package/dist/epub/index.js +11 -18
  34. package/dist/epub/index.js.map +1 -1
  35. package/dist/html/index.d.ts +315 -15
  36. package/dist/html/index.js +32 -165
  37. package/dist/html/index.js.map +1 -1
  38. package/dist/index.d.ts +10 -38
  39. package/dist/index.js +80 -37
  40. package/dist/index.js.map +1 -1
  41. package/dist/ooxml/index.d.ts +353 -14
  42. package/dist/ooxml/index.js +116 -22
  43. package/dist/ooxml/index.js.map +1 -1
  44. package/dist/pdf/index.d.ts +143 -17
  45. package/dist/pdf/index.js +16 -25
  46. package/dist/pdf/index.js.map +1 -1
  47. package/dist/pptx/index.d.ts +98 -21
  48. package/dist/pptx/index.js +18 -34
  49. package/dist/pptx/index.js.map +1 -1
  50. package/dist/xlsx/index.d.ts +29 -25
  51. package/dist/xlsx/index.js +13 -44
  52. package/dist/xlsx/index.js.map +1 -1
  53. package/package.json +7 -2
  54. package/src/__tests__/csvImport.test.ts +51 -0
  55. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  56. package/src/__tests__/htmlImport.test.ts +57 -0
  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 +70 -0
  60. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  61. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  62. package/src/__tests__/roundTripMatrix.test.ts +136 -0
  63. package/src/__tests__/xlsxImport.test.ts +80 -0
  64. package/src/csv/index.ts +165 -0
  65. package/src/docx/export.ts +27 -44
  66. package/src/docx/import.ts +1 -2
  67. package/src/epub/export.ts +10 -27
  68. package/src/html/import.ts +297 -0
  69. package/src/html/index.ts +4 -0
  70. package/src/html/plainHtml.ts +66 -16
  71. package/src/index.ts +10 -3
  72. package/src/ooxml/namespaces.ts +3 -0
  73. package/src/pdf/export.ts +10 -5
  74. package/src/pdf/import.ts +1 -1
  75. package/src/pptx/export.ts +225 -85
  76. package/src/pptx/import.ts +156 -0
  77. package/src/pptx/index.ts +10 -28
  78. package/src/shared/inlineRuns.ts +99 -0
  79. package/src/shared/text.ts +41 -0
  80. package/src/xlsx/import.ts +159 -0
  81. package/src/xlsx/index.ts +11 -28
  82. package/dist/__tests__/container.test.d.ts +0 -8
  83. package/dist/__tests__/container.test.d.ts.map +0 -1
  84. package/dist/__tests__/container.test.js +0 -147
  85. package/dist/__tests__/container.test.js.map +0 -1
  86. package/dist/__tests__/docxExport.test.d.ts +0 -8
  87. package/dist/__tests__/docxExport.test.d.ts.map +0 -1
  88. package/dist/__tests__/docxExport.test.js +0 -383
  89. package/dist/__tests__/docxExport.test.js.map +0 -1
  90. package/dist/__tests__/docxImport.test.d.ts +0 -8
  91. package/dist/__tests__/docxImport.test.d.ts.map +0 -1
  92. package/dist/__tests__/docxImport.test.js +0 -309
  93. package/dist/__tests__/docxImport.test.js.map +0 -1
  94. package/dist/__tests__/epub.test.d.ts +0 -8
  95. package/dist/__tests__/epub.test.d.ts.map +0 -1
  96. package/dist/__tests__/epub.test.js +0 -472
  97. package/dist/__tests__/epub.test.js.map +0 -1
  98. package/dist/__tests__/html.test.d.ts +0 -8
  99. package/dist/__tests__/html.test.d.ts.map +0 -1
  100. package/dist/__tests__/html.test.js +0 -317
  101. package/dist/__tests__/html.test.js.map +0 -1
  102. package/dist/__tests__/ooxml.test.d.ts +0 -5
  103. package/dist/__tests__/ooxml.test.d.ts.map +0 -1
  104. package/dist/__tests__/ooxml.test.js +0 -152
  105. package/dist/__tests__/ooxml.test.js.map +0 -1
  106. package/dist/__tests__/pdfExport.test.d.ts +0 -8
  107. package/dist/__tests__/pdfExport.test.d.ts.map +0 -1
  108. package/dist/__tests__/pdfExport.test.js +0 -275
  109. package/dist/__tests__/pdfExport.test.js.map +0 -1
  110. package/dist/__tests__/pdfImport.test.d.ts +0 -11
  111. package/dist/__tests__/pdfImport.test.d.ts.map +0 -1
  112. package/dist/__tests__/pdfImport.test.js +0 -254
  113. package/dist/__tests__/pdfImport.test.js.map +0 -1
  114. package/dist/__tests__/plainHtml.test.d.ts +0 -5
  115. package/dist/__tests__/plainHtml.test.d.ts.map +0 -1
  116. package/dist/__tests__/plainHtml.test.js +0 -327
  117. package/dist/__tests__/plainHtml.test.js.map +0 -1
  118. package/dist/__tests__/plainHtmlBundle.test.d.ts +0 -11
  119. package/dist/__tests__/plainHtmlBundle.test.d.ts.map +0 -1
  120. package/dist/__tests__/plainHtmlBundle.test.js +0 -210
  121. package/dist/__tests__/plainHtmlBundle.test.js.map +0 -1
  122. package/dist/__tests__/roundTrip.test.d.ts +0 -7
  123. package/dist/__tests__/roundTrip.test.d.ts.map +0 -1
  124. package/dist/__tests__/roundTrip.test.js +0 -173
  125. package/dist/__tests__/roundTrip.test.js.map +0 -1
  126. package/dist/chunk-33YRFXZZ.js.map +0 -1
  127. package/dist/chunk-67KIJHV2.js +0 -21
  128. package/dist/chunk-67KIJHV2.js.map +0 -1
  129. package/dist/chunk-7DEUGIOO.js.map +0 -1
  130. package/dist/chunk-ERZ627GR.js.map +0 -1
  131. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  132. package/dist/chunk-UDS45KUJ.js.map +0 -1
  133. package/dist/chunk-VN2KEOYB.js.map +0 -1
  134. package/dist/chunk-YN5HFCEW.js +0 -120
  135. package/dist/chunk-YN5HFCEW.js.map +0 -1
  136. package/dist/container/index.d.ts.map +0 -1
  137. package/dist/docx/export.d.ts +0 -69
  138. package/dist/docx/export.d.ts.map +0 -1
  139. package/dist/docx/export.js +0 -985
  140. package/dist/docx/export.js.map +0 -1
  141. package/dist/docx/import.d.ts +0 -65
  142. package/dist/docx/import.d.ts.map +0 -1
  143. package/dist/docx/import.js +0 -779
  144. package/dist/docx/import.js.map +0 -1
  145. package/dist/docx/index.d.ts.map +0 -1
  146. package/dist/docx/styles.d.ts +0 -68
  147. package/dist/docx/styles.d.ts.map +0 -1
  148. package/dist/docx/styles.js +0 -125
  149. package/dist/docx/styles.js.map +0 -1
  150. package/dist/epub/export.d.ts +0 -72
  151. package/dist/epub/export.d.ts.map +0 -1
  152. package/dist/epub/export.js +0 -698
  153. package/dist/epub/export.js.map +0 -1
  154. package/dist/epub/index.d.ts.map +0 -1
  155. package/dist/html/docsHtmlBundle.d.ts +0 -53
  156. package/dist/html/docsHtmlBundle.d.ts.map +0 -1
  157. package/dist/html/docsHtmlBundle.js +0 -299
  158. package/dist/html/docsHtmlBundle.js.map +0 -1
  159. package/dist/html/htmlTemplate.d.ts +0 -70
  160. package/dist/html/htmlTemplate.d.ts.map +0 -1
  161. package/dist/html/htmlTemplate.js +0 -282
  162. package/dist/html/htmlTemplate.js.map +0 -1
  163. package/dist/html/imageUtils.d.ts +0 -29
  164. package/dist/html/imageUtils.d.ts.map +0 -1
  165. package/dist/html/imageUtils.js +0 -63
  166. package/dist/html/imageUtils.js.map +0 -1
  167. package/dist/html/index.d.ts.map +0 -1
  168. package/dist/html/plainHtml.d.ts +0 -79
  169. package/dist/html/plainHtml.d.ts.map +0 -1
  170. package/dist/html/plainHtml.js +0 -614
  171. package/dist/html/plainHtml.js.map +0 -1
  172. package/dist/html/plainHtmlBundle.d.ts +0 -93
  173. package/dist/html/plainHtmlBundle.d.ts.map +0 -1
  174. package/dist/html/plainHtmlBundle.js +0 -357
  175. package/dist/html/plainHtmlBundle.js.map +0 -1
  176. package/dist/index.d.ts.map +0 -1
  177. package/dist/ooxml/index.d.ts.map +0 -1
  178. package/dist/ooxml/namespaces.d.ts +0 -77
  179. package/dist/ooxml/namespaces.d.ts.map +0 -1
  180. package/dist/ooxml/namespaces.js +0 -104
  181. package/dist/ooxml/namespaces.js.map +0 -1
  182. package/dist/ooxml/reader.d.ts +0 -52
  183. package/dist/ooxml/reader.d.ts.map +0 -1
  184. package/dist/ooxml/reader.js +0 -190
  185. package/dist/ooxml/reader.js.map +0 -1
  186. package/dist/ooxml/types.d.ts +0 -82
  187. package/dist/ooxml/types.d.ts.map +0 -1
  188. package/dist/ooxml/types.js +0 -9
  189. package/dist/ooxml/types.js.map +0 -1
  190. package/dist/ooxml/writer.d.ts +0 -71
  191. package/dist/ooxml/writer.d.ts.map +0 -1
  192. package/dist/ooxml/writer.js +0 -200
  193. package/dist/ooxml/writer.js.map +0 -1
  194. package/dist/ooxml/xmlUtils.d.ts +0 -76
  195. package/dist/ooxml/xmlUtils.d.ts.map +0 -1
  196. package/dist/ooxml/xmlUtils.js +0 -108
  197. package/dist/ooxml/xmlUtils.js.map +0 -1
  198. package/dist/pdf/export.d.ts +0 -56
  199. package/dist/pdf/export.d.ts.map +0 -1
  200. package/dist/pdf/export.js +0 -768
  201. package/dist/pdf/export.js.map +0 -1
  202. package/dist/pdf/import.d.ts +0 -94
  203. package/dist/pdf/import.d.ts.map +0 -1
  204. package/dist/pdf/import.js +0 -796
  205. package/dist/pdf/import.js.map +0 -1
  206. package/dist/pdf/index.d.ts.map +0 -1
  207. package/dist/pdf/styles.d.ts +0 -142
  208. package/dist/pdf/styles.d.ts.map +0 -1
  209. package/dist/pdf/styles.js +0 -145
  210. package/dist/pdf/styles.js.map +0 -1
  211. package/dist/pptx/export.d.ts +0 -75
  212. package/dist/pptx/export.d.ts.map +0 -1
  213. package/dist/pptx/export.js +0 -724
  214. package/dist/pptx/export.js.map +0 -1
  215. package/dist/pptx/index.d.ts.map +0 -1
  216. package/dist/pptx/styles.d.ts +0 -54
  217. package/dist/pptx/styles.d.ts.map +0 -1
  218. package/dist/pptx/styles.js +0 -73
  219. package/dist/pptx/styles.js.map +0 -1
  220. package/dist/pptx/templates.d.ts +0 -24
  221. package/dist/pptx/templates.d.ts.map +0 -1
  222. package/dist/pptx/templates.js +0 -158
  223. package/dist/pptx/templates.js.map +0 -1
  224. package/dist/xlsx/index.d.ts.map +0 -1
@@ -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
+ });
@@ -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
+ }
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
22
- import { docToMarkdown } from '@bendyline/squisq/doc';
21
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
22
+ import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
23
23
  import type {
24
24
  MarkdownDocument,
25
25
  MarkdownBlockNode,
@@ -44,6 +44,12 @@ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
44
44
 
45
45
  import { createPackage } from '../ooxml/writer.js';
46
46
  import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
47
+ import { stripHtmlTags } from '../shared/text.js';
48
+ import {
49
+ inlineNodesToRuns,
50
+ inlineNodeToRuns,
51
+ type InlineRunHandlers,
52
+ } from '../shared/inlineRuns.js';
47
53
  import {
48
54
  NS_WML,
49
55
  NS_R,
@@ -126,7 +132,7 @@ export async function markdownDocToDocx(
126
132
  options.themeId !== undefined
127
133
  ? options
128
134
  : { ...options, themeId: readFrontmatterThemeId(doc.frontmatter) };
129
- const ctx = new ExportContext(resolvedOptions);
135
+ const ctx = new ExportContext(resolvedOptions, doc);
130
136
  const bodyXml = convertBlocks(doc.children, ctx);
131
137
  return buildDocxPackage(bodyXml, ctx, resolvedOptions);
132
138
  }
@@ -206,7 +212,7 @@ class ExportContext {
206
212
 
207
213
  private nextDocPrId = 1;
208
214
 
209
- constructor(options: DocxExportOptions) {
215
+ constructor(options: DocxExportOptions, doc?: MarkdownDocument) {
210
216
  let themeFont: string | undefined;
211
217
  let themeTitleFont: string | undefined;
212
218
  let themeHeadingColor: string | undefined;
@@ -215,7 +221,7 @@ class ExportContext {
215
221
  let themeBackgroundColor: string | undefined;
216
222
 
217
223
  if (options.themeId) {
218
- const theme: Theme = resolveTheme(options.themeId);
224
+ const theme: Theme = resolveThemeForDoc(doc, options.themeId);
219
225
  // Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
220
226
  // "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
221
227
  // single font name — passing the whole stack is treated as a
@@ -631,46 +637,30 @@ interface InlineFormat {
631
637
  color?: string;
632
638
  }
633
639
 
640
+ /**
641
+ * DOCX leaf handlers for the shared run-based inline walker. The traversal
642
+ * (format threading) lives in `shared/inlineRuns.ts`; these emit WordprocessingML.
643
+ */
644
+ function docxRunHandlers(ctx: ExportContext): InlineRunHandlers {
645
+ return {
646
+ run: (text, format) => makeRun(text, format),
647
+ link: (node, format) => convertLink(node, ctx, format),
648
+ image: (node) => convertImage(node, ctx),
649
+ lineBreak: () => `<w:r><w:br/></w:r>`,
650
+ footnoteRef: (node) => convertFootnoteRef(node, ctx),
651
+ };
652
+ }
653
+
634
654
  function convertInlines(
635
655
  nodes: MarkdownInlineNode[],
636
656
  ctx: ExportContext,
637
657
  format: InlineFormat = {},
638
658
  ): string {
639
- const parts: string[] = [];
640
- for (const node of nodes) {
641
- parts.push(convertInline(node, ctx, format));
642
- }
643
- return parts.join('');
659
+ return inlineNodesToRuns(nodes, docxRunHandlers(ctx), format);
644
660
  }
645
661
 
646
662
  function convertInline(node: MarkdownInlineNode, ctx: ExportContext, format: InlineFormat): string {
647
- switch (node.type) {
648
- case 'text':
649
- return makeRun(node.value, format);
650
- case 'strong':
651
- return convertInlines(node.children, ctx, { ...format, bold: true });
652
- case 'emphasis':
653
- return convertInlines(node.children, ctx, { ...format, italic: true });
654
- case 'delete':
655
- return convertInlines(node.children, ctx, { ...format, strike: true });
656
- case 'inlineCode':
657
- return makeRun(node.value, { ...format, code: true });
658
- case 'link':
659
- return convertLink(node, ctx, format);
660
- case 'image':
661
- return convertImage(node, ctx);
662
- case 'break':
663
- return `<w:r><w:br/></w:r>`;
664
- case 'htmlInline':
665
- return makeRun(stripHtmlTags(node.rawHtml), format);
666
- case 'inlineMath':
667
- return makeRun(node.value, { ...format, code: true });
668
- case 'footnoteReference':
669
- return convertFootnoteRef(node, ctx);
670
- default:
671
- // linkReference, imageReference, textDirective — skip or emit plain
672
- return '';
673
- }
663
+ return inlineNodeToRuns(node, docxRunHandlers(ctx), format);
674
664
  }
675
665
 
676
666
  function makeRun(text: string, format: InlineFormat): string {
@@ -1273,10 +1263,3 @@ function buildFootnotesXml(ctx: ExportContext): string {
1273
1263
  // ============================================
1274
1264
  // Helpers
1275
1265
  // ============================================
1276
-
1277
- /**
1278
- * Strip HTML tags from a string, keeping only text content.
1279
- */
1280
- function stripHtmlTags(html: string): string {
1281
- return html.replace(/<[^>]*>/g, '');
1282
- }
@@ -21,6 +21,7 @@
21
21
 
22
22
  import type { Doc } from '@bendyline/squisq/schemas';
23
23
  import { markdownToDoc } from '@bendyline/squisq/doc';
24
+ import { stringifyMarkdown } from '@bendyline/squisq/markdown';
24
25
  import type {
25
26
  MarkdownDocument,
26
27
  MarkdownBlockNode,
@@ -156,8 +157,6 @@ export async function docxToContainer(
156
157
  const blocks = await convertBody(body, ctx);
157
158
  const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
158
159
 
159
- // Serialize to markdown
160
- const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
161
160
  const markdown = stringifyMarkdown(markdownDoc);
162
161
 
163
162
  // Build container with markdown + images
@@ -23,7 +23,8 @@
23
23
 
24
24
  import JSZip from 'jszip';
25
25
  import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
26
- import { resolveTheme, resolveFontFamily } from '@bendyline/squisq/schemas';
26
+ import { resolveFontFamily } from '@bendyline/squisq/schemas';
27
+ import { resolveThemeForDoc } from '@bendyline/squisq/doc';
27
28
  import type {
28
29
  MarkdownDocument,
29
30
  MarkdownBlockNode,
@@ -35,8 +36,10 @@ import type {
35
36
  MarkdownTableRow,
36
37
  MarkdownTableCell,
37
38
  } from '@bendyline/squisq/markdown';
39
+ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
38
40
  import { escapeXml } from '../ooxml/xmlUtils.js';
39
41
  import { inferMimeType, extractFilename } from '../html/imageUtils.js';
42
+ import { extractPlainText } from '../shared/text.js';
40
43
 
41
44
  // ── Public API ────────────────────────────────────────────────────
42
45
 
@@ -119,8 +122,9 @@ export async function markdownDocToEpub(
119
122
  }
120
123
  }
121
124
 
122
- // Generate theme CSS
123
- const css = generateStylesheet(options.themeId);
125
+ // Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
126
+ // theme (`squisq-theme` / legacy), mirroring the other export formats.
127
+ const css = generateStylesheet(options.themeId ?? readFrontmatterThemeId(doc.frontmatter), doc);
124
128
 
125
129
  // Build the ZIP
126
130
  const zip = new JSZip();
@@ -360,28 +364,7 @@ function splitIntoChapters(nodes: MarkdownBlockNode[]): Chapter[] {
360
364
  }
361
365
 
362
366
  function extractHeadingText(heading: MarkdownHeading): string {
363
- return heading.children.map(inlineToText).join('');
364
- }
365
-
366
- function inlineToText(node: MarkdownInlineNode): string {
367
- switch (node.type) {
368
- case 'text':
369
- return node.value;
370
- case 'emphasis':
371
- case 'strong':
372
- case 'delete':
373
- return node.children.map(inlineToText).join('');
374
- case 'inlineCode':
375
- return node.value;
376
- case 'link':
377
- return node.children.map(inlineToText).join('');
378
- case 'image':
379
- return node.alt ?? '';
380
- case 'break':
381
- return ' ';
382
- default:
383
- return '';
384
- }
367
+ return extractPlainText(heading.children);
385
368
  }
386
369
 
387
370
  // ── Image Collection ──────────────────────────────────────────────
@@ -795,10 +778,10 @@ ${navItems}
795
778
 
796
779
  // ── Stylesheet ────────────────────────────────────────────────────
797
780
 
798
- function generateStylesheet(themeId?: string): string {
781
+ function generateStylesheet(themeId: string | undefined, doc?: MarkdownDocument): string {
799
782
  let themeVars = '';
800
783
  if (themeId) {
801
- const theme = resolveTheme(themeId);
784
+ const theme = resolveThemeForDoc(doc, themeId);
802
785
  themeVars = `
803
786
  --epub-bg: ${theme.colors.background};
804
787
  --epub-text: ${theme.colors.text};