@bendyline/squisq-formats 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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/csv/index.d.ts +37 -0
  24. package/dist/csv/index.js +13 -0
  25. package/dist/csv/index.js.map +1 -0
  26. package/dist/docx/index.js +5 -3
  27. package/dist/epub/index.js +2 -1
  28. package/dist/html/index.d.ts +39 -2
  29. package/dist/html/index.js +7 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +31 -13
  32. package/dist/ooxml/index.js +16 -16
  33. package/dist/pdf/index.js +1 -1
  34. package/dist/pptx/index.d.ts +20 -18
  35. package/dist/pptx/index.js +6 -2
  36. package/dist/xlsx/index.d.ts +20 -19
  37. package/dist/xlsx/index.js +2 -1
  38. package/package.json +7 -2
  39. package/src/__tests__/csvImport.test.ts +51 -0
  40. package/src/__tests__/exportThemeReconciliation.test.ts +87 -0
  41. package/src/__tests__/htmlImport.test.ts +57 -0
  42. package/src/__tests__/plainHtml.test.ts +31 -0
  43. package/src/__tests__/pptxExport.test.ts +138 -0
  44. package/src/__tests__/pptxImport.test.ts +70 -0
  45. package/src/__tests__/roundTripMatrix.fixtures.ts +86 -0
  46. package/src/__tests__/roundTripMatrix.helpers.ts +154 -0
  47. package/src/__tests__/roundTripMatrix.test.ts +136 -0
  48. package/src/__tests__/xlsxImport.test.ts +80 -0
  49. package/src/csv/index.ts +165 -0
  50. package/src/docx/export.ts +27 -44
  51. package/src/docx/import.ts +1 -2
  52. package/src/epub/export.ts +10 -27
  53. package/src/html/import.ts +297 -0
  54. package/src/html/index.ts +4 -0
  55. package/src/html/plainHtml.ts +66 -16
  56. package/src/index.ts +10 -3
  57. package/src/ooxml/namespaces.ts +3 -0
  58. package/src/pdf/export.ts +10 -5
  59. package/src/pdf/import.ts +1 -1
  60. package/src/pptx/export.ts +225 -85
  61. package/src/pptx/import.ts +156 -0
  62. package/src/pptx/index.ts +10 -28
  63. package/src/shared/inlineRuns.ts +99 -0
  64. package/src/shared/text.ts +41 -0
  65. package/src/xlsx/import.ts +159 -0
  66. package/src/xlsx/index.ts +11 -28
  67. package/dist/chunk-33YRFXZZ.js.map +0 -1
  68. package/dist/chunk-67KIJHV2.js +0 -21
  69. package/dist/chunk-67KIJHV2.js.map +0 -1
  70. package/dist/chunk-7DEUGIOO.js.map +0 -1
  71. package/dist/chunk-ERZ627GR.js.map +0 -1
  72. package/dist/chunk-S3Y7H2BK.js.map +0 -1
  73. package/dist/chunk-UDS45KUJ.js.map +0 -1
  74. package/dist/chunk-VN2KEOYB.js.map +0 -1
  75. package/dist/chunk-YN5HFCEW.js +0 -120
  76. package/dist/chunk-YN5HFCEW.js.map +0 -1
@@ -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();