@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,297 @@
1
+ /**
2
+ * HTML Import Module — @bendyline/squisq-formats/html
3
+ *
4
+ * Converts an HTML document (or fragment) into a squisq `MarkdownDocument`.
5
+ * Built on the existing `parseHtmlToNodes` (parse5-backed) + `sanitizeHtmlNodes`
6
+ * from `@bendyline/squisq/markdown`, so no new dependency is introduced.
7
+ *
8
+ * The primary consumer is email: HTML mail bodies are hostile input, so the
9
+ * parse is sanitized by default (scripts/styles/event handlers/dangerous URLs
10
+ * stripped) before the tree is walked into markdown nodes.
11
+ *
12
+ * Two entry points mirror the docx/pdf importers:
13
+ * - `htmlToMarkdownDoc(data, options?)` → `MarkdownDocument`
14
+ * - `htmlToMarkdown(html, options?)` → markdown string (convenience for the
15
+ * string-in/string-out path email body conversion needs)
16
+ */
17
+
18
+ import {
19
+ type MarkdownBlockNode,
20
+ type MarkdownDocument,
21
+ type MarkdownInlineNode,
22
+ type MarkdownListItem,
23
+ type MarkdownTableCell,
24
+ type MarkdownTableRow,
25
+ type HtmlElement,
26
+ type HtmlNode,
27
+ parseHtmlToNodes,
28
+ sanitizeHtmlNodes,
29
+ stringifyMarkdown,
30
+ } from '@bendyline/squisq/markdown';
31
+
32
+ export interface HtmlImportOptions {
33
+ /**
34
+ * Strip scripts / styles / event handlers / dangerous URLs before walking
35
+ * the tree. Default `true` — HTML email is untrusted; only disable for
36
+ * trusted input where you want raw fidelity.
37
+ */
38
+ sanitize?: boolean;
39
+ }
40
+
41
+ // ── tag classification ──────────────────────────────────────────────
42
+
43
+ const HEADINGS: Record<string, 1 | 2 | 3 | 4 | 5 | 6> = {
44
+ h1: 1,
45
+ h2: 2,
46
+ h3: 3,
47
+ h4: 4,
48
+ h5: 5,
49
+ h6: 6,
50
+ };
51
+
52
+ // Containers with no semantic block meaning — unwrap and walk their children.
53
+ const TRANSPARENT_BLOCK = new Set([
54
+ 'div',
55
+ 'section',
56
+ 'article',
57
+ 'header',
58
+ 'footer',
59
+ 'main',
60
+ 'aside',
61
+ 'nav',
62
+ 'figure',
63
+ 'figcaption',
64
+ 'form',
65
+ 'fieldset',
66
+ 'body',
67
+ 'html',
68
+ 'center',
69
+ ]);
70
+
71
+ // Elements that carry no content we want (and whose text must not leak).
72
+ const DROP = new Set(['script', 'style', 'head', 'title', 'noscript', 'template', 'svg']);
73
+
74
+ const INLINE_STRONG = new Set(['strong', 'b']);
75
+ const INLINE_EM = new Set(['em', 'i']);
76
+ const INLINE_DEL = new Set(['del', 's', 'strike']);
77
+ const INLINE_TRANSPARENT = new Set(['span', 'font', 'abbr', 'mark', 'small', 'sub', 'sup', 'u']);
78
+
79
+ const isElement = (n: HtmlNode): n is HtmlElement => n.type === 'htmlElement';
80
+ const isText = (n: HtmlNode): n is { type: 'htmlText'; value: string } => n.type === 'htmlText';
81
+
82
+ const collapseWs = (s: string): string => s.replace(/\s+/g, ' ');
83
+
84
+ // ── inline conversion ───────────────────────────────────────────────
85
+
86
+ function inlinesFromNodes(nodes: HtmlNode[]): MarkdownInlineNode[] {
87
+ const out: MarkdownInlineNode[] = [];
88
+ for (const node of nodes) {
89
+ if (isText(node)) {
90
+ const value = collapseWs(node.value);
91
+ if (value) out.push({ type: 'text', value });
92
+ continue;
93
+ }
94
+ if (!isElement(node)) continue; // comment
95
+ const tag = node.tagName.toLowerCase();
96
+ if (DROP.has(tag)) continue;
97
+
98
+ if (tag === 'br') {
99
+ out.push({ type: 'break' });
100
+ } else if (INLINE_STRONG.has(tag)) {
101
+ out.push({ type: 'strong', children: inlinesFromNodes(node.children) });
102
+ } else if (INLINE_EM.has(tag)) {
103
+ out.push({ type: 'emphasis', children: inlinesFromNodes(node.children) });
104
+ } else if (INLINE_DEL.has(tag)) {
105
+ out.push({ type: 'delete', children: inlinesFromNodes(node.children) });
106
+ } else if (tag === 'code' || tag === 'kbd' || tag === 'samp' || tag === 'tt') {
107
+ out.push({ type: 'inlineCode', value: textContent(node) });
108
+ } else if (tag === 'a') {
109
+ const url = node.attributes.href ?? '';
110
+ const children = inlinesFromNodes(node.children);
111
+ out.push({
112
+ type: 'link',
113
+ url,
114
+ children: children.length > 0 ? children : [{ type: 'text', value: url }],
115
+ });
116
+ } else if (tag === 'img') {
117
+ const url = node.attributes.src ?? '';
118
+ const alt = node.attributes.alt;
119
+ if (url) out.push({ type: 'image', url, ...(alt ? { alt } : {}) });
120
+ } else {
121
+ // Unknown / transparent inline (span, font, …): flatten children.
122
+ out.push(...inlinesFromNodes(node.children));
123
+ }
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** Flatten an element's descendant text (for code blocks / inline code). */
129
+ function textContent(node: HtmlNode): string {
130
+ if (isText(node)) return node.value;
131
+ if (isElement(node)) return node.children.map(textContent).join('');
132
+ return '';
133
+ }
134
+
135
+ const onlyWhitespace = (inlines: MarkdownInlineNode[]): boolean =>
136
+ inlines.every((n) => n.type === 'text' && n.value.trim() === '');
137
+
138
+ // ── block conversion ────────────────────────────────────────────────
139
+
140
+ function blocksFromNodes(nodes: HtmlNode[]): MarkdownBlockNode[] {
141
+ const out: MarkdownBlockNode[] = [];
142
+ let inlineBuffer: HtmlNode[] = [];
143
+
144
+ const flush = () => {
145
+ if (inlineBuffer.length === 0) return;
146
+ const inlines = inlinesFromNodes(inlineBuffer);
147
+ inlineBuffer = [];
148
+ if (inlines.length > 0 && !onlyWhitespace(inlines)) {
149
+ out.push({ type: 'paragraph', children: inlines });
150
+ }
151
+ };
152
+
153
+ for (const node of nodes) {
154
+ if (isText(node)) {
155
+ inlineBuffer.push(node);
156
+ continue;
157
+ }
158
+ if (!isElement(node)) continue;
159
+ const tag = node.tagName.toLowerCase();
160
+ if (DROP.has(tag)) continue;
161
+
162
+ const block = blockForElement(node, tag);
163
+ if (block === 'inline') {
164
+ inlineBuffer.push(node);
165
+ } else if (block) {
166
+ flush();
167
+ out.push(...block);
168
+ }
169
+ }
170
+ flush();
171
+ return out;
172
+ }
173
+
174
+ /** Returns the block node(s) for a block element, `'inline'` for inline ones. */
175
+ function blockForElement(node: HtmlElement, tag: string): MarkdownBlockNode[] | 'inline' | null {
176
+ if (tag in HEADINGS) {
177
+ const children = inlinesFromNodes(node.children);
178
+ return [{ type: 'heading', depth: HEADINGS[tag]!, children }];
179
+ }
180
+ if (tag === 'p') {
181
+ const children = inlinesFromNodes(node.children);
182
+ return children.length > 0 && !onlyWhitespace(children)
183
+ ? [{ type: 'paragraph', children }]
184
+ : [];
185
+ }
186
+ if (tag === 'br') return 'inline';
187
+ if (tag === 'hr') return [{ type: 'thematicBreak' }];
188
+ if (tag === 'blockquote') {
189
+ return [{ type: 'blockquote', children: blocksFromNodes(node.children) }];
190
+ }
191
+ if (tag === 'pre') {
192
+ return [{ type: 'code', value: stripTrailingNewline(textContent(node)) }];
193
+ }
194
+ if (tag === 'ul' || tag === 'ol') {
195
+ return [listFromElement(node, tag === 'ol')];
196
+ }
197
+ if (tag === 'table') {
198
+ const table = tableFromElement(node);
199
+ return table ? [table] : [];
200
+ }
201
+ if (TRANSPARENT_BLOCK.has(tag)) {
202
+ return blocksFromNodes(node.children);
203
+ }
204
+ if (
205
+ INLINE_STRONG.has(tag) ||
206
+ INLINE_EM.has(tag) ||
207
+ INLINE_DEL.has(tag) ||
208
+ INLINE_TRANSPARENT.has(tag) ||
209
+ tag === 'a' ||
210
+ tag === 'img' ||
211
+ tag === 'code' ||
212
+ tag === 'kbd' ||
213
+ tag === 'samp'
214
+ ) {
215
+ return 'inline';
216
+ }
217
+ // Unknown element: treat as a transparent container so its content survives.
218
+ return blocksFromNodes(node.children);
219
+ }
220
+
221
+ function listFromElement(node: HtmlElement, ordered: boolean): MarkdownBlockNode {
222
+ const items: MarkdownListItem[] = [];
223
+ for (const child of node.children) {
224
+ if (isElement(child) && child.tagName.toLowerCase() === 'li') {
225
+ const blocks = blocksFromNodes(child.children);
226
+ items.push({ type: 'listItem', children: blocks });
227
+ }
228
+ }
229
+ const startAttr = node.attributes.start;
230
+ const start = ordered && startAttr ? Number.parseInt(startAttr, 10) : undefined;
231
+ return {
232
+ type: 'list',
233
+ ordered,
234
+ ...(start !== undefined && Number.isFinite(start) ? { start } : {}),
235
+ children: items,
236
+ };
237
+ }
238
+
239
+ function tableFromElement(node: HtmlElement): MarkdownBlockNode | null {
240
+ const rows: MarkdownTableRow[] = [];
241
+ const collectRows = (n: HtmlElement) => {
242
+ for (const child of n.children) {
243
+ if (!isElement(child)) continue;
244
+ const t = child.tagName.toLowerCase();
245
+ if (t === 'tr') {
246
+ const cells: MarkdownTableCell[] = [];
247
+ for (const cell of child.children) {
248
+ if (
249
+ isElement(cell) &&
250
+ (cell.tagName.toLowerCase() === 'td' || cell.tagName.toLowerCase() === 'th')
251
+ ) {
252
+ cells.push({ type: 'tableCell', children: inlinesFromNodes(cell.children) });
253
+ }
254
+ }
255
+ if (cells.length > 0) rows.push({ type: 'tableRow', children: cells });
256
+ } else if (t === 'thead' || t === 'tbody' || t === 'tfoot') {
257
+ collectRows(child);
258
+ }
259
+ }
260
+ };
261
+ collectRows(node);
262
+ if (rows.length === 0) return null;
263
+ return { type: 'table', children: rows };
264
+ }
265
+
266
+ const stripTrailingNewline = (s: string): string => s.replace(/\n+$/, '');
267
+
268
+ // ── public API ──────────────────────────────────────────────────────
269
+
270
+ function toHtmlString(data: ArrayBuffer | Uint8Array | string): string {
271
+ if (typeof data === 'string') return data;
272
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
273
+ return new TextDecoder('utf-8').decode(bytes);
274
+ }
275
+
276
+ /** Parse HTML into a squisq `MarkdownDocument`. */
277
+ export function htmlToMarkdownDocSync(
278
+ html: string,
279
+ options: HtmlImportOptions = {},
280
+ ): MarkdownDocument {
281
+ let nodes = parseHtmlToNodes(html);
282
+ if (options.sanitize !== false) nodes = sanitizeHtmlNodes(nodes);
283
+ return { type: 'document', children: blocksFromNodes(nodes) };
284
+ }
285
+
286
+ /** Async, ArrayBuffer-accepting entry mirroring docx/pdf importers. */
287
+ export async function htmlToMarkdownDoc(
288
+ data: ArrayBuffer | Uint8Array | string,
289
+ options: HtmlImportOptions = {},
290
+ ): Promise<MarkdownDocument> {
291
+ return htmlToMarkdownDocSync(toHtmlString(data), options);
292
+ }
293
+
294
+ /** Convenience string→string conversion (used for email HTML bodies). */
295
+ export function htmlToMarkdown(html: string, options: HtmlImportOptions = {}): string {
296
+ return stringifyMarkdown(htmlToMarkdownDocSync(html, options));
297
+ }
package/src/html/index.ts CHANGED
@@ -195,3 +195,7 @@ export { markdownDocsToPlainHtmlBundle, collectLinkRefs } from './plainHtmlBundl
195
195
  export type { PlainHtmlBundleOptions } from './plainHtmlBundle.js';
196
196
  export { markdownDocsToHtmlBundle } from './docsHtmlBundle.js';
197
197
  export type { HtmlBundleOptions } from './docsHtmlBundle.js';
198
+
199
+ // ── Import (HTML → MarkdownDocument) ────────────────────────────────
200
+ export { htmlToMarkdown, htmlToMarkdownDoc, htmlToMarkdownDocSync } from './import.js';
201
+ export type { HtmlImportOptions } from './import.js';
@@ -14,9 +14,18 @@
14
14
  * blob URLs (live preview) or data URIs (single-file export).
15
15
  */
16
16
 
17
- import type { MarkdownDocument, MarkdownNode, HtmlNode } from '@bendyline/squisq/markdown';
17
+ import {
18
+ sanitizeHtmlNodes,
19
+ sanitizeUrl,
20
+ type HtmlPolicy,
21
+ type MarkdownDocument,
22
+ type MarkdownNode,
23
+ type HtmlNode,
24
+ readFrontmatterThemeId,
25
+ } from '@bendyline/squisq/markdown';
18
26
  import type { Theme } from '@bendyline/squisq/schemas';
19
- import { resolveFontFamily, buildGoogleFontsUrl, resolveTheme } from '@bendyline/squisq/schemas';
27
+ import { resolveFontFamily, buildGoogleFontsUrl } from '@bendyline/squisq/schemas';
28
+ import { resolveThemeForDoc } from '@bendyline/squisq/doc';
20
29
 
21
30
  // ── Public Types ───────────────────────────────────────────────────
22
31
 
@@ -71,6 +80,11 @@ export interface PlainHtmlExportOptions {
71
80
  * option is not provided.
72
81
  */
73
82
  iconsCss?: string;
83
+ /**
84
+ * Raw HTML policy. Defaults to `sanitize`, which removes unsafe tags,
85
+ * event handlers, and executable URL schemes before emitting HTML.
86
+ */
87
+ htmlPolicy?: HtmlPolicy;
74
88
  }
75
89
 
76
90
  /**
@@ -81,6 +95,7 @@ export interface PlainHtmlExportOptions {
81
95
  interface RenderCtx {
82
96
  images?: Map<string, string>;
83
97
  links?: Map<string, string>;
98
+ htmlPolicy: HtmlPolicy;
84
99
  }
85
100
 
86
101
  // ── Public API ─────────────────────────────────────────────────────
@@ -97,19 +112,20 @@ export function markdownDocToPlainHtml(
97
112
  doc: MarkdownDocument,
98
113
  options: PlainHtmlExportOptions = {},
99
114
  ): string {
100
- const { title = 'Document', images, links, themeId, iconsCss } = options;
115
+ const { title = 'Document', images, links, themeId, iconsCss, htmlPolicy = 'sanitize' } = options;
101
116
  // Fall back chain for theme: explicit `theme` → explicit `themeId`
102
117
  // option → doc frontmatter `themeId`. Hosts whose export dialog
103
118
  // tracks themes by id can pass `themeId` straight through; authored
104
119
  // docs with `themeId: warm-earth` in frontmatter get styled
105
120
  // automatically when neither is supplied.
106
- const theme =
107
- options.theme ??
108
- (themeId ? resolveTheme(themeId) : undefined) ??
109
- (typeof doc.frontmatter?.themeId === 'string'
110
- ? resolveTheme(doc.frontmatter.themeId)
111
- : undefined);
112
- const ctx: RenderCtx = { images, links };
121
+ // Resolution is doc-scoped via `resolveThemeForDoc`, so an inline
122
+ // `squisq-custom-themes` id resolves here with no global registration —
123
+ // and `readFrontmatterThemeId` picks up the editor's canonical
124
+ // `squisq-theme` key. Stays undefined when nothing selects a theme so
125
+ // un-themed exports render unstyled (unchanged behavior).
126
+ const resolveId = themeId ?? readFrontmatterThemeId(doc.frontmatter);
127
+ const theme = options.theme ?? (resolveId ? resolveThemeForDoc(doc, resolveId) : undefined);
128
+ const ctx: RenderCtx = { images, links, htmlPolicy };
113
129
  const body = renderTopLevel(doc.children, ctx);
114
130
  const fontsLink = theme ? renderFontsLink(theme) : '';
115
131
  // Resolve how to load FontAwesome — only when the doc actually uses
@@ -258,7 +274,9 @@ interface FeaturedImage {
258
274
 
259
275
  function renderFeatureImage(img: FeaturedImage, ctx: RenderCtx | undefined): string {
260
276
  const resolved = ctx?.images?.get(img.src) ?? img.src;
261
- const attrs = [`src="${escapeAttr(resolved)}"`, `alt="${escapeAttr(img.alt)}"`];
277
+ const safeSrc = sanitizeUrl(resolved, 'media');
278
+ if (!safeSrc) return '<div class="squisq-feature__media squisq-feature__media--empty"></div>';
279
+ const attrs = [`src="${escapeAttr(safeSrc)}"`, `alt="${escapeAttr(img.alt)}"`];
262
280
  // Emit `width` / `height` attributes only when the source HTML had
263
281
  // them. The CSS rules then know to honor those values rather than
264
282
  // stretching the image to fill the column.
@@ -604,12 +622,16 @@ function nodeToHtml(node: MarkdownNode | undefined | null, ctx?: RenderCtx): str
604
622
  case 'link': {
605
623
  const original = node.url ?? '';
606
624
  const rewritten = ctx?.links?.get(original) ?? original;
607
- return `<a href="${escapeAttr(rewritten)}">${childrenToHtml(node, ctx)}</a>`;
625
+ const safeHref = sanitizeUrl(rewritten, 'link');
626
+ if (!safeHref) return childrenToHtml(node, ctx);
627
+ return `<a href="${escapeAttr(safeHref)}">${childrenToHtml(node, ctx)}</a>`;
608
628
  }
609
629
  case 'image': {
610
630
  const original = node.url ?? '';
611
631
  const resolved = ctx?.images?.get(original) ?? original;
612
- return `<img src="${escapeAttr(resolved)}" alt="${escapeAttr(node.alt ?? '')}" />`;
632
+ const safeSrc = sanitizeUrl(resolved, 'media');
633
+ if (!safeSrc) return escapeHtml(node.alt ?? '');
634
+ return `<img src="${escapeAttr(safeSrc)}" alt="${escapeAttr(node.alt ?? '')}" />`;
613
635
  }
614
636
  case 'thematicBreak':
615
637
  return '<hr />';
@@ -626,11 +648,12 @@ function nodeToHtml(node: MarkdownNode | undefined | null, ctx?: RenderCtx): str
626
648
  }
627
649
  case 'htmlBlock':
628
650
  case 'htmlInline':
651
+ if (resolveHtmlPolicy(ctx) === 'strip') return '';
629
652
  // Resized images and other authored HTML survive the round-trip
630
653
  // as parsed `htmlChildren` — rewriting `<img src>` through the
631
654
  // image map keeps the preview consistent with the markdown-image
632
655
  // path. Other tags pass through unmodified.
633
- return htmlChildrenToHtml(node.htmlChildren, ctx);
656
+ return htmlChildrenToHtml(resolveHtmlNodes(node.htmlChildren, ctx), ctx);
634
657
  default: {
635
658
  // Unknown / unhandled node — recurse into children if any so we
636
659
  // don't drop content (e.g. directives, footnotes).
@@ -682,18 +705,28 @@ function tableToHtml(
682
705
  return parts.join('');
683
706
  }
684
707
 
708
+ function resolveHtmlPolicy(ctx: RenderCtx | undefined): HtmlPolicy {
709
+ return ctx?.htmlPolicy ?? 'sanitize';
710
+ }
711
+
712
+ function resolveHtmlNodes(nodes: HtmlNode[] | undefined, ctx: RenderCtx | undefined): HtmlNode[] {
713
+ if (!nodes || nodes.length === 0) return [];
714
+ return resolveHtmlPolicy(ctx) === 'trusted' ? nodes : sanitizeHtmlNodes(nodes);
715
+ }
716
+
685
717
  function htmlChildrenToHtml(nodes: HtmlNode[] | undefined, ctx?: RenderCtx): string {
686
718
  if (!nodes || nodes.length === 0) return '';
719
+ const trusted = resolveHtmlPolicy(ctx) === 'trusted';
687
720
  const out: string[] = [];
688
721
  for (const node of nodes) {
689
722
  if (node.type === 'htmlText') {
690
723
  // HtmlText already represents authored HTML — emit verbatim so
691
724
  // entity references the user wrote (e.g. `&amp;`) survive.
692
- out.push(node.value);
725
+ out.push(trusted ? node.value : escapeHtml(node.value));
693
726
  continue;
694
727
  }
695
728
  if (node.type === 'htmlComment') {
696
- out.push(`<!--${node.value}-->`);
729
+ if (trusted) out.push(`<!--${node.value}-->`);
697
730
  continue;
698
731
  }
699
732
  // htmlElement
@@ -713,6 +746,23 @@ function htmlChildrenToHtml(nodes: HtmlNode[] | undefined, ctx?: RenderCtx): str
713
746
  if ((tag === 'video' || tag === 'audio') && typeof attrs.poster === 'string') {
714
747
  attrs.poster = ctx?.images?.get(attrs.poster) ?? attrs.poster;
715
748
  }
749
+ if (!trusted) {
750
+ if (typeof attrs.src === 'string') {
751
+ const safeSrc = sanitizeUrl(attrs.src, 'media');
752
+ if (!safeSrc) delete attrs.src;
753
+ else attrs.src = safeSrc;
754
+ }
755
+ if (typeof attrs.poster === 'string') {
756
+ const safePoster = sanitizeUrl(attrs.poster, 'media');
757
+ if (!safePoster) delete attrs.poster;
758
+ else attrs.poster = safePoster;
759
+ }
760
+ if (typeof attrs.href === 'string') {
761
+ const safeHref = sanitizeUrl(attrs.href, 'link');
762
+ if (!safeHref) delete attrs.href;
763
+ else attrs.href = safeHref;
764
+ }
765
+ }
716
766
  const attrStr = Object.entries(attrs)
717
767
  .map(([k, v]) => ` ${k}="${escapeAttr(v)}"`)
718
768
  .join('');
package/src/index.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * Supported formats:
8
8
  * - **DOCX** — Microsoft Word (import + export) ✅
9
9
  * - **PDF** — Portable Document Format (import + export) ✅
10
- * - **PPTX** — Microsoft PowerPoint (export ✅, import planned)
11
- * - **XLSX** — Microsoft Excel (planned)
10
+ * - **PPTX** — Microsoft PowerPoint (export ✅, import )
11
+ * - **XLSX** — Microsoft Excel (import ✅, export planned)
12
+ * - **CSV** — Comma-separated values (import ✅, export ✅)
12
13
  *
13
14
  * All converters run in the browser — no server or native binaries required.
14
15
  * The shared `ooxml/` subpath export provides reusable OOXML infrastructure.
@@ -32,10 +33,14 @@ export type { DocxExportOptions, DocxImportOptions } from './docx/index.js';
32
33
  export { markdownDocToPptx, docToPptx, pptxToMarkdownDoc, pptxToDoc } from './pptx/index.js';
33
34
  export type { PptxExportOptions, PptxImportOptions } from './pptx/index.js';
34
35
 
35
- // XLSX (stub)
36
+ // XLSX (import implemented, export stub)
36
37
  export { markdownDocToXlsx, docToXlsx, xlsxToMarkdownDoc, xlsxToDoc } from './xlsx/index.js';
37
38
  export type { XlsxExportOptions, XlsxImportOptions } from './xlsx/index.js';
38
39
 
40
+ // CSV (import + export)
41
+ export { csvToMarkdownDoc, csvToDoc, markdownDocToCsv, parseCsv } from './csv/index.js';
42
+ export type { CsvImportOptions, CsvExportOptions } from './csv/index.js';
43
+
39
44
  // PDF (fully implemented)
40
45
  export {
41
46
  markdownDocToPdf,
@@ -49,6 +54,8 @@ export type { PdfExportOptions, PdfImportOptions } from './pdf/index.js';
49
54
  // HTML (fully implemented)
50
55
  export { docToHtml, docToHtmlZip, collectImagePaths } from './html/index.js';
51
56
  export type { HtmlExportOptions, HtmlZipExportOptions } from './html/index.js';
57
+ export { htmlToMarkdown, htmlToMarkdownDoc, htmlToMarkdownDocSync } from './html/index.js';
58
+ export type { HtmlImportOptions } from './html/index.js';
52
59
 
53
60
  // EPUB (export)
54
61
  export { markdownDocToEpub, docToEpub } from './epub/index.js';
@@ -77,6 +77,9 @@ export const NS_WML = 'http://schemas.openxmlformats.org/wordprocessingml/2006/m
77
77
  /** PresentationML main namespace (p:) */
78
78
  export const NS_PML = 'http://schemas.openxmlformats.org/presentationml/2006/main';
79
79
 
80
+ /** PowerPoint 2010 extension namespace (p14:) */
81
+ export const NS_PML_2010 = 'http://schemas.microsoft.com/office/powerpoint/2010/main';
82
+
80
83
  // ============================================
81
84
  // SpreadsheetML (XLSX)
82
85
  // ============================================
package/src/pdf/export.ts CHANGED
@@ -22,8 +22,7 @@
22
22
  import { PDFDocument, StandardFonts, rgb, PDFFont, PDFPage } from 'pdf-lib';
23
23
 
24
24
  import type { Doc } from '@bendyline/squisq/schemas';
25
- import { resolveTheme } from '@bendyline/squisq/schemas';
26
- import { docToMarkdown } from '@bendyline/squisq/doc';
25
+ import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
27
26
  import type {
28
27
  MarkdownDocument,
29
28
  MarkdownBlockNode,
@@ -51,6 +50,7 @@ import type {
51
50
  MarkdownInlineMath,
52
51
  MarkdownFootnoteReference,
53
52
  } from '@bendyline/squisq/markdown';
53
+ import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
54
54
 
55
55
  import {
56
56
  PAGE_WIDTH_LETTER,
@@ -127,7 +127,7 @@ export async function markdownDocToPdf(
127
127
  pdfDoc.setCreationDate(new Date());
128
128
  pdfDoc.setModificationDate(new Date());
129
129
 
130
- const ctx = await createExportContext(pdfDoc, options);
130
+ const ctx = await createExportContext(pdfDoc, options, doc);
131
131
 
132
132
  renderBlocks(doc.children, ctx, 0);
133
133
 
@@ -190,6 +190,7 @@ interface ExportContext {
190
190
  async function createExportContext(
191
191
  pdfDoc: PDFDocument,
192
192
  options: PdfExportOptions,
193
+ doc: MarkdownDocument,
193
194
  ): Promise<ExportContext> {
194
195
  const [regular, bold, italic, boldItalic, mono, monoBold] = await Promise.all([
195
196
  pdfDoc.embedFont(StandardFonts.Helvetica),
@@ -213,8 +214,12 @@ async function createExportContext(
213
214
  let colorHeading = COLOR_HEADING;
214
215
  let colorLink = COLOR_LINK;
215
216
 
216
- if (options.themeId) {
217
- const theme = resolveTheme(options.themeId);
217
+ // Honor an explicit themeId, else the doc's frontmatter theme (`squisq-theme`
218
+ // / legacy) — mirroring PPTX/DOCX/HTML so every format themes consistently,
219
+ // including inline custom themes (resolved doc-scoped).
220
+ const themeId = options.themeId ?? readFrontmatterThemeId(doc.frontmatter);
221
+ if (themeId) {
222
+ const theme = resolveThemeForDoc(doc, themeId);
218
223
  if (theme.colors) {
219
224
  colorText = hexToRgb(theme.colors.text) ?? COLOR_TEXT;
220
225
  colorHeading = hexToRgb(theme.colors.primary) ?? COLOR_HEADING;
package/src/pdf/import.ts CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { Doc } from '@bendyline/squisq/schemas';
24
24
  import { markdownToDoc } from '@bendyline/squisq/doc';
25
+ import { stringifyMarkdown } from '@bendyline/squisq/markdown';
25
26
  import type {
26
27
  MarkdownDocument,
27
28
  MarkdownBlockNode,
@@ -169,7 +170,6 @@ export async function pdfToContainer(
169
170
 
170
171
  const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
171
172
 
172
- const { stringifyMarkdown } = await import('@bendyline/squisq/markdown');
173
173
  const markdown = stringifyMarkdown(markdownDoc);
174
174
 
175
175
  const container = new MemoryContentContainer();