@bendyline/squisq-formats 1.0.1 → 1.1.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 (79) hide show
  1. package/README.md +23 -10
  2. package/dist/__tests__/container.test.d.ts +8 -0
  3. package/dist/__tests__/container.test.d.ts.map +1 -0
  4. package/dist/__tests__/container.test.js +147 -0
  5. package/dist/__tests__/container.test.js.map +1 -0
  6. package/dist/{chunk-MQHCXI56.js → chunk-6OVREALI.js} +31 -13
  7. package/dist/chunk-6OVREALI.js.map +1 -0
  8. package/dist/{chunk-ULLIPBEJ.js → chunk-743COJWQ.js} +6 -2
  9. package/dist/chunk-743COJWQ.js.map +1 -0
  10. package/dist/{chunk-KAK4V57E.js → chunk-BHHEDPRB.js} +16 -112
  11. package/dist/chunk-BHHEDPRB.js.map +1 -0
  12. package/dist/chunk-FIFCQN3W.js +630 -0
  13. package/dist/chunk-FIFCQN3W.js.map +1 -0
  14. package/dist/chunk-KJ4NS4DX.js +120 -0
  15. package/dist/chunk-KJ4NS4DX.js.map +1 -0
  16. package/dist/{chunk-TBPD5PCU.js → chunk-PP5N46YD.js} +33 -11
  17. package/dist/chunk-PP5N46YD.js.map +1 -0
  18. package/dist/container/index.d.ts +36 -0
  19. package/dist/container/index.d.ts.map +1 -0
  20. package/dist/container/index.js +74 -0
  21. package/dist/container/index.js.map +1 -0
  22. package/dist/docx/export.d.ts +6 -0
  23. package/dist/docx/export.d.ts.map +1 -1
  24. package/dist/docx/export.js +24 -7
  25. package/dist/docx/export.js.map +1 -1
  26. package/dist/html/htmlTemplate.d.ts +6 -0
  27. package/dist/html/htmlTemplate.d.ts.map +1 -1
  28. package/dist/html/htmlTemplate.js +5 -1
  29. package/dist/html/htmlTemplate.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/ooxml/index.d.ts +1 -1
  34. package/dist/ooxml/index.d.ts.map +1 -1
  35. package/dist/ooxml/index.js +1 -1
  36. package/dist/ooxml/index.js.map +1 -1
  37. package/dist/ooxml/namespaces.d.ts +10 -0
  38. package/dist/ooxml/namespaces.d.ts.map +1 -1
  39. package/dist/ooxml/namespaces.js +10 -0
  40. package/dist/ooxml/namespaces.js.map +1 -1
  41. package/dist/pdf/export.d.ts +6 -0
  42. package/dist/pdf/export.d.ts.map +1 -1
  43. package/dist/pdf/export.js +37 -9
  44. package/dist/pdf/export.js.map +1 -1
  45. package/dist/pptx/export.d.ts +75 -0
  46. package/dist/pptx/export.d.ts.map +1 -0
  47. package/dist/pptx/export.js +722 -0
  48. package/dist/pptx/export.js.map +1 -0
  49. package/dist/pptx/index.d.ts +12 -27
  50. package/dist/pptx/index.d.ts.map +1 -1
  51. package/dist/pptx/index.js +12 -22
  52. package/dist/pptx/index.js.map +1 -1
  53. package/dist/pptx/styles.d.ts +54 -0
  54. package/dist/pptx/styles.d.ts.map +1 -0
  55. package/dist/pptx/styles.js +73 -0
  56. package/dist/pptx/styles.js.map +1 -0
  57. package/dist/pptx/templates.d.ts +24 -0
  58. package/dist/pptx/templates.d.ts.map +1 -0
  59. package/dist/pptx/templates.js +158 -0
  60. package/dist/pptx/templates.js.map +1 -0
  61. package/package.json +7 -2
  62. package/src/__tests__/container.test.ts +189 -0
  63. package/src/container/index.ts +89 -0
  64. package/src/docx/export.ts +36 -8
  65. package/src/html/htmlTemplate.ts +13 -0
  66. package/src/index.ts +2 -2
  67. package/src/ooxml/index.ts +7 -0
  68. package/src/ooxml/namespaces.ts +23 -0
  69. package/src/pdf/export.ts +57 -9
  70. package/src/pptx/export.ts +1041 -0
  71. package/src/pptx/index.ts +15 -37
  72. package/src/pptx/styles.ts +96 -0
  73. package/src/pptx/templates.ts +187 -0
  74. package/dist/chunk-532L4D5D.js +0 -21
  75. package/dist/chunk-532L4D5D.js.map +0 -1
  76. package/dist/chunk-KAK4V57E.js.map +0 -1
  77. package/dist/chunk-MQHCXI56.js.map +0 -1
  78. package/dist/chunk-TBPD5PCU.js.map +0 -1
  79. package/dist/chunk-ULLIPBEJ.js.map +0 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Tests for container ZIP serialization: containerToZip, zipToContainer.
3
+ *
4
+ * Verifies round-trip fidelity: write files to a container, serialize to ZIP,
5
+ * deserialize back, and confirm all files are intact.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import JSZip from 'jszip';
10
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
11
+ import { containerToZip, zipToContainer } from '../container/index';
12
+
13
+ // ============================================
14
+ // Helpers
15
+ // ============================================
16
+
17
+ /** Helper to read a Blob as ArrayBuffer (works in jsdom) */
18
+ async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
19
+ return new Promise((resolve, reject) => {
20
+ const reader = new FileReader();
21
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
22
+ reader.onerror = () => reject(reader.error);
23
+ reader.readAsArrayBuffer(blob);
24
+ });
25
+ }
26
+
27
+ // ============================================
28
+ // containerToZip
29
+ // ============================================
30
+
31
+ describe('containerToZip', () => {
32
+ it('creates a valid ZIP from an empty container', async () => {
33
+ const container = new MemoryContentContainer();
34
+ const blob = await containerToZip(container);
35
+ expect(blob).toBeInstanceOf(Blob);
36
+ expect(blob.size).toBeGreaterThan(0);
37
+
38
+ // Should be parseable as a ZIP with no files
39
+ const zip = await JSZip.loadAsync(await blobToArrayBuffer(blob));
40
+ expect(Object.keys(zip.files)).toHaveLength(0);
41
+ });
42
+
43
+ it('includes all files from the container', async () => {
44
+ const container = new MemoryContentContainer();
45
+ await container.writeDocument('# Hello World');
46
+ await container.writeFile('images/hero.jpg', new Uint8Array([0xff, 0xd8, 0xff]), 'image/jpeg');
47
+ await container.writeFile('timing.json', new TextEncoder().encode('{"duration":10}'));
48
+
49
+ const blob = await containerToZip(container);
50
+ const zip = await JSZip.loadAsync(await blobToArrayBuffer(blob));
51
+
52
+ const filenames = Object.keys(zip.files)
53
+ .filter((f) => !zip.files[f].dir)
54
+ .sort();
55
+ expect(filenames).toEqual(['images/hero.jpg', 'index.md', 'timing.json']);
56
+ });
57
+
58
+ it('preserves file content', async () => {
59
+ const container = new MemoryContentContainer();
60
+ const markdown = '# Test Document\n\nSome content.';
61
+ await container.writeDocument(markdown);
62
+
63
+ const blob = await containerToZip(container);
64
+ const zip = await JSZip.loadAsync(await blobToArrayBuffer(blob));
65
+
66
+ const content = await zip.file('index.md')!.async('string');
67
+ expect(content).toBe(markdown);
68
+ });
69
+
70
+ it('preserves binary content', async () => {
71
+ const container = new MemoryContentContainer();
72
+ const binaryData = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
73
+ await container.writeFile('image.png', binaryData, 'image/png');
74
+
75
+ const blob = await containerToZip(container);
76
+ const zip = await JSZip.loadAsync(await blobToArrayBuffer(blob));
77
+
78
+ const data = await zip.file('image.png')!.async('uint8array');
79
+ expect(data).toEqual(binaryData);
80
+ });
81
+ });
82
+
83
+ // ============================================
84
+ // zipToContainer
85
+ // ============================================
86
+
87
+ describe('zipToContainer', () => {
88
+ it('loads files from a ZIP', async () => {
89
+ const zip = new JSZip();
90
+ zip.file('index.md', '# Hello');
91
+ zip.file('images/photo.jpg', new Uint8Array([0xff, 0xd8]));
92
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
93
+
94
+ const container = await zipToContainer(zipBlob);
95
+ const entries = await container.listFiles();
96
+ expect(entries.map((e) => e.path).sort()).toEqual(['images/photo.jpg', 'index.md']);
97
+ });
98
+
99
+ it('preserves text content', async () => {
100
+ const zip = new JSZip();
101
+ zip.file('index.md', '# My Document\n\nParagraph.');
102
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
103
+
104
+ const container = await zipToContainer(zipBlob);
105
+ expect(await container.readDocument()).toBe('# My Document\n\nParagraph.');
106
+ });
107
+
108
+ it('preserves binary content', async () => {
109
+ const original = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
110
+ const zip = new JSZip();
111
+ zip.file('data.bin', original);
112
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
113
+
114
+ const container = await zipToContainer(zipBlob);
115
+ const data = await container.readFile('data.bin');
116
+ expect(new Uint8Array(data!)).toEqual(original);
117
+ });
118
+
119
+ it('skips directory entries', async () => {
120
+ const zip = new JSZip();
121
+ zip.folder('images');
122
+ zip.file('images/photo.jpg', new Uint8Array([0xff]));
123
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
124
+
125
+ const container = await zipToContainer(zipBlob);
126
+ const entries = await container.listFiles();
127
+ // Only the file, not the directory
128
+ expect(entries).toHaveLength(1);
129
+ expect(entries[0].path).toBe('images/photo.jpg');
130
+ });
131
+
132
+ it('works with ArrayBuffer input', async () => {
133
+ const zip = new JSZip();
134
+ zip.file('readme.md', '# Read me');
135
+ const ab = await zip.generateAsync({ type: 'arraybuffer' });
136
+
137
+ const container = await zipToContainer(ab);
138
+ expect(await container.readDocument()).toBe('# Read me');
139
+ });
140
+ });
141
+
142
+ // ============================================
143
+ // Round-trip
144
+ // ============================================
145
+
146
+ describe('containerToZip / zipToContainer round-trip', () => {
147
+ it('round-trips a container with markdown and media', async () => {
148
+ // Create original container
149
+ const original = new MemoryContentContainer();
150
+ const markdown = '# Round Trip Test\n\n![hero](images/hero.jpg)\n\nSome text.';
151
+ await original.writeDocument(markdown);
152
+ await original.writeFile(
153
+ 'images/hero.jpg',
154
+ new Uint8Array([0xff, 0xd8, 0xff, 0xe0]),
155
+ 'image/jpeg',
156
+ );
157
+ await original.writeFile(
158
+ 'audio/narration.mp3',
159
+ new Uint8Array([0x49, 0x44, 0x33]),
160
+ 'audio/mpeg',
161
+ );
162
+ await original.writeFile('timing.json', new TextEncoder().encode('{"segments":[]}'));
163
+
164
+ // Serialize to ZIP
165
+ const zipBlob = await containerToZip(original);
166
+
167
+ // Deserialize back
168
+ const restored = await zipToContainer(zipBlob);
169
+
170
+ // Verify document
171
+ expect(await restored.readDocument()).toBe(markdown);
172
+ expect(await restored.getDocumentPath()).toBe('index.md');
173
+
174
+ // Verify all files present
175
+ const entries = (await restored.listFiles()).map((e) => e.path).sort();
176
+ expect(entries).toEqual(['audio/narration.mp3', 'images/hero.jpg', 'index.md', 'timing.json']);
177
+
178
+ // Verify binary content
179
+ const heroData = new Uint8Array((await restored.readFile('images/hero.jpg'))!);
180
+ expect(heroData).toEqual(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]));
181
+
182
+ const audioData = new Uint8Array((await restored.readFile('audio/narration.mp3'))!);
183
+ expect(audioData).toEqual(new Uint8Array([0x49, 0x44, 0x33]));
184
+
185
+ // Verify JSON sidecar
186
+ const timingData = new TextDecoder().decode((await restored.readFile('timing.json'))!);
187
+ expect(timingData).toBe('{"segments":[]}');
188
+ });
189
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Container ZIP serialization — convert between ContentContainer and ZIP archives.
3
+ *
4
+ * Uses JSZip (already a formats dependency) to serialize a ContentContainer to
5
+ * a ZIP blob and to deserialize a ZIP blob into a MemoryContentContainer.
6
+ *
7
+ * ZIP structure mirrors the container's flat path hierarchy directly:
8
+ * index.md
9
+ * images/hero.jpg
10
+ * audio/narration.mp3
11
+ * timing.json
12
+ */
13
+
14
+ import JSZip from 'jszip';
15
+ import type { ContentContainer } from '@bendyline/squisq/storage';
16
+ import { MemoryContentContainer } from '@bendyline/squisq/storage';
17
+
18
+ /**
19
+ * Serialize a ContentContainer to a ZIP blob.
20
+ *
21
+ * All files in the container are written to the ZIP archive preserving
22
+ * their path structure. The resulting blob can be saved as a .zip file.
23
+ *
24
+ * @param container — The container to serialize
25
+ * @returns A Blob containing the ZIP archive
26
+ */
27
+ export async function containerToZip(container: ContentContainer): Promise<Blob> {
28
+ const zip = new JSZip();
29
+ const entries = await container.listFiles();
30
+
31
+ for (const entry of entries) {
32
+ const data = await container.readFile(entry.path);
33
+ if (data) {
34
+ zip.file(entry.path, new Uint8Array(data));
35
+ }
36
+ }
37
+
38
+ return zip.generateAsync({
39
+ type: 'blob',
40
+ compression: 'DEFLATE',
41
+ compressionOptions: { level: 6 },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Deserialize a ZIP archive into a MemoryContentContainer.
47
+ *
48
+ * Reads all files from the ZIP and writes them into a new MemoryContentContainer.
49
+ * Directory entries are skipped. The resulting container can be used immediately
50
+ * for rendering, editing, or saving to persistent storage.
51
+ *
52
+ * @param zipData — The ZIP archive as ArrayBuffer, Uint8Array, or Blob
53
+ * @returns A MemoryContentContainer populated with the ZIP's contents
54
+ */
55
+ export async function zipToContainer(
56
+ zipData: ArrayBuffer | Uint8Array | Blob,
57
+ ): Promise<MemoryContentContainer> {
58
+ const zip = await JSZip.loadAsync(zipData);
59
+ const container = new MemoryContentContainer();
60
+
61
+ const filePromises: Promise<void>[] = [];
62
+
63
+ zip.forEach((relativePath, zipEntry) => {
64
+ // Skip directories
65
+ if (zipEntry.dir) return;
66
+
67
+ // Strip leading slash if present
68
+ const entryPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
69
+ if (!entryPath) return;
70
+
71
+ // Path-traversal protection: reject absolute paths, backslashes, and .. segments
72
+ if (
73
+ entryPath.startsWith('/') ||
74
+ entryPath.includes('\\') ||
75
+ entryPath.split('/').some((seg) => seg === '..')
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ filePromises.push(
81
+ zipEntry.async('arraybuffer').then((data) => {
82
+ return container.writeFile(entryPath, data);
83
+ }),
84
+ );
85
+ });
86
+
87
+ await Promise.all(filePromises);
88
+ return container;
89
+ }
@@ -17,7 +17,8 @@
17
17
  * ```
18
18
  */
19
19
 
20
- import type { Doc } from '@bendyline/squisq/schemas';
20
+ import type { Doc, Theme } from '@bendyline/squisq/schemas';
21
+ import { resolveTheme } from '@bendyline/squisq/schemas';
21
22
  import { docToMarkdown } from '@bendyline/squisq/doc';
22
23
  import type {
23
24
  MarkdownDocument,
@@ -90,6 +91,12 @@ export interface DocxExportOptions {
90
91
  defaultFont?: string;
91
92
  /** Default body font size in points. Default: 11 */
92
93
  defaultFontSize?: number;
94
+ /**
95
+ * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
96
+ * When set, overrides fonts with the theme's typography and applies
97
+ * the theme's primary color to headings.
98
+ */
99
+ themeId?: string;
93
100
  }
94
101
 
95
102
  /**
@@ -167,13 +174,32 @@ class ExportContext {
167
174
  hasFootnotes = false;
168
175
 
169
176
  readonly font: string;
177
+ readonly headingFont: string;
170
178
  readonly fontSize: number;
179
+ /** Heading text color (hex without #), or undefined for default */
180
+ readonly headingColor: string | undefined;
171
181
 
172
182
  constructor(options: DocxExportOptions) {
173
- this.font = options.defaultFont ?? DEFAULT_FONT;
183
+ let themeFont: string | undefined;
184
+ let themeTitleFont: string | undefined;
185
+ let themeHeadingColor: string | undefined;
186
+
187
+ if (options.themeId) {
188
+ const theme: Theme = resolveTheme(options.themeId);
189
+ themeFont = theme.typography?.bodyFontFamily;
190
+ themeTitleFont = theme.typography?.titleFontFamily;
191
+ if (theme.colors?.primary) {
192
+ const c = theme.colors.primary;
193
+ themeHeadingColor = c.startsWith('#') ? c.slice(1) : c;
194
+ }
195
+ }
196
+
197
+ this.font = options.defaultFont ?? themeFont ?? DEFAULT_FONT;
198
+ this.headingFont = themeTitleFont ?? this.font;
174
199
  this.fontSize = options.defaultFontSize
175
200
  ? options.defaultFontSize * 2
176
201
  : DEFAULT_FONT_SIZE_HALF_POINTS;
202
+ this.headingColor = themeHeadingColor;
177
203
  }
178
204
 
179
205
  /** Allocate a new relationship ID */
@@ -632,7 +658,7 @@ async function buildDocxPackage(
632
658
  pkg.addPart('word/document.xml', documentXml, CONTENT_TYPE_DOCX_DOCUMENT);
633
659
 
634
660
  // --- word/styles.xml ---
635
- const stylesXml = buildStylesXml(options);
661
+ const stylesXml = buildStylesXml(options, ctx);
636
662
  pkg.addPart('word/styles.xml', stylesXml, CONTENT_TYPE_DOCX_STYLES);
637
663
 
638
664
  // --- word/settings.xml ---
@@ -754,9 +780,9 @@ function buildDocumentXml(bodyXml: string): string {
754
780
  );
755
781
  }
756
782
 
757
- function buildStylesXml(options: DocxExportOptions): string {
758
- const font = options.defaultFont ?? DEFAULT_FONT;
759
- const headingFont = DEFAULT_HEADING_FONT;
783
+ function buildStylesXml(options: DocxExportOptions, ctx: ExportContext): string {
784
+ const font = ctx.font;
785
+ const headingFont = ctx.headingFont;
760
786
 
761
787
  return (
762
788
  xmlDeclaration() +
@@ -776,7 +802,7 @@ function buildStylesXml(options: DocxExportOptions): string {
776
802
  `<w:qFormat/>` +
777
803
  `</w:style>` +
778
804
  // Heading styles
779
- buildHeadingStyles(headingFont) +
805
+ buildHeadingStyles(headingFont, ctx.headingColor) +
780
806
  // Quote style
781
807
  `<w:style w:type="paragraph" w:styleId="Quote">` +
782
808
  `<w:name w:val="Quote"/>` +
@@ -827,11 +853,12 @@ function buildStylesXml(options: DocxExportOptions): string {
827
853
  );
828
854
  }
829
855
 
830
- function buildHeadingStyles(headingFont: string): string {
856
+ function buildHeadingStyles(headingFont: string, headingColor?: string): string {
831
857
  let result = '';
832
858
  for (let depth = 1; depth <= 6; depth++) {
833
859
  const styleId = DEPTH_TO_STYLE_ID[depth];
834
860
  const fontSize = HEADING_FONT_SIZES[depth] ?? 22;
861
+ const colorXml = headingColor ? `<w:color w:val="${headingColor}"/>` : '';
835
862
  result +=
836
863
  `<w:style w:type="paragraph" w:styleId="${styleId}">` +
837
864
  `<w:name w:val="heading ${depth}"/>` +
@@ -842,6 +869,7 @@ function buildHeadingStyles(headingFont: string): string {
842
869
  `<w:rPr>` +
843
870
  `<w:rFonts w:ascii="${escapeXml(headingFont)}" w:hAnsi="${escapeXml(headingFont)}"/>` +
844
871
  `<w:b/>` +
872
+ colorXml +
845
873
  `<w:sz w:val="${fontSize}"/>` +
846
874
  `<w:szCs w:val="${fontSize}"/>` +
847
875
  `</w:rPr>` +
@@ -41,6 +41,13 @@ export interface HtmlExportOptions {
41
41
 
42
42
  /** Auto-play slideshow on load (default: false) */
43
43
  autoPlay?: boolean;
44
+
45
+ /**
46
+ * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
47
+ * When set, the theme is assigned to the Doc before rendering,
48
+ * so the SquisqPlayer renders with that theme's colors and typography.
49
+ */
50
+ themeId?: string;
44
51
  }
45
52
 
46
53
  // ── Image Path Collection ──────────────────────────────────────────
@@ -182,8 +189,14 @@ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string
182
189
  mode = 'slideshow',
183
190
  title = 'Squisq Document',
184
191
  autoPlay = false,
192
+ themeId,
185
193
  } = options;
186
194
 
195
+ // Apply theme to doc if specified (the SquisqPlayer respects doc.themeId at render time)
196
+ if (themeId) {
197
+ doc = { ...doc, themeId };
198
+ }
199
+
187
200
  // Build base64 image map
188
201
  const imageMap: Record<string, string> = {};
189
202
  if (images) {
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * Supported formats:
8
8
  * - **DOCX** — Microsoft Word (import + export) ✅
9
9
  * - **PDF** — Portable Document Format (import + export) ✅
10
- * - **PPTX** — Microsoft PowerPoint (planned)
10
+ * - **PPTX** — Microsoft PowerPoint (export ✅, import planned)
11
11
  * - **XLSX** — Microsoft Excel (planned)
12
12
  *
13
13
  * All converters run in the browser — no server or native binaries required.
@@ -28,7 +28,7 @@
28
28
  export { markdownDocToDocx, docToDocx, docxToMarkdownDoc, docxToDoc } from './docx/index.js';
29
29
  export type { DocxExportOptions, DocxImportOptions } from './docx/index.js';
30
30
 
31
- // PPTX (stub)
31
+ // PPTX (export implemented, import stub)
32
32
  export { markdownDocToPptx, docToPptx, pptxToMarkdownDoc, pptxToDoc } from './pptx/index.js';
33
33
  export type { PptxExportOptions, PptxImportOptions } from './pptx/index.js';
34
34
 
@@ -83,5 +83,12 @@ export {
83
83
  CONTENT_TYPE_DOCX_FONT_TABLE,
84
84
  CONTENT_TYPE_DOCX_FOOTNOTES,
85
85
  CONTENT_TYPE_PPTX_PRESENTATION,
86
+ CONTENT_TYPE_PPTX_SLIDE,
87
+ CONTENT_TYPE_PPTX_SLIDE_LAYOUT,
88
+ CONTENT_TYPE_PPTX_SLIDE_MASTER,
89
+ CONTENT_TYPE_PPTX_THEME,
90
+ REL_SLIDE,
91
+ REL_SLIDE_LAYOUT,
92
+ REL_SLIDE_MASTER,
86
93
  CONTENT_TYPE_XLSX_WORKBOOK,
87
94
  } from './namespaces.js';
@@ -156,5 +156,28 @@ export const CONTENT_TYPE_DOCX_FOOTNOTES =
156
156
  export const CONTENT_TYPE_PPTX_PRESENTATION =
157
157
  'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml';
158
158
 
159
+ export const CONTENT_TYPE_PPTX_SLIDE =
160
+ 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
161
+
162
+ export const CONTENT_TYPE_PPTX_SLIDE_LAYOUT =
163
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml';
164
+
165
+ export const CONTENT_TYPE_PPTX_SLIDE_MASTER =
166
+ 'application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml';
167
+
168
+ export const CONTENT_TYPE_PPTX_THEME = 'application/vnd.openxmlformats-officedocument.theme+xml';
169
+
170
+ /** Relationship type: Slide */
171
+ export const REL_SLIDE =
172
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide';
173
+
174
+ /** Relationship type: Slide layout */
175
+ export const REL_SLIDE_LAYOUT =
176
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout';
177
+
178
+ /** Relationship type: Slide master */
179
+ export const REL_SLIDE_MASTER =
180
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster';
181
+
159
182
  export const CONTENT_TYPE_XLSX_WORKBOOK =
160
183
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml';
package/src/pdf/export.ts CHANGED
@@ -22,6 +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';
25
26
  import { docToMarkdown } from '@bendyline/squisq/doc';
26
27
  import type {
27
28
  MarkdownDocument,
@@ -103,6 +104,12 @@ export interface PdfExportOptions {
103
104
  margin?: number;
104
105
  /** Default body font size in points. Default: 11. */
105
106
  defaultFontSize?: number;
107
+ /**
108
+ * Squisq theme ID to apply (e.g., 'documentary', 'cinematic').
109
+ * When set, overrides heading, text, and link colors with the theme palette.
110
+ * Font changes are not supported (pdf-lib uses standard 14 PDF fonts only).
111
+ */
112
+ themeId?: string;
106
113
  }
107
114
 
108
115
  /**
@@ -151,6 +158,12 @@ interface FontSet {
151
158
  monoBold: PDFFont;
152
159
  }
153
160
 
161
+ interface RgbColor {
162
+ r: number;
163
+ g: number;
164
+ b: number;
165
+ }
166
+
154
167
  interface ExportContext {
155
168
  pdfDoc: PDFDocument;
156
169
  fonts: FontSet;
@@ -166,6 +179,12 @@ interface ExportContext {
166
179
  contentWidth: number;
167
180
  /** Bottom margin y position. */
168
181
  bottomY: number;
182
+ /** Resolved colors (may be overridden by theme). */
183
+ colors: {
184
+ text: RgbColor;
185
+ heading: RgbColor;
186
+ link: RgbColor;
187
+ };
169
188
  }
170
189
 
171
190
  async function createExportContext(
@@ -189,6 +208,20 @@ async function createExportContext(
189
208
 
190
209
  const page = pdfDoc.addPage([pageWidth, pageHeight]);
191
210
 
211
+ // Resolve theme colors if themeId is set
212
+ let colorText = COLOR_TEXT;
213
+ let colorHeading = COLOR_HEADING;
214
+ let colorLink = COLOR_LINK;
215
+
216
+ if (options.themeId) {
217
+ const theme = resolveTheme(options.themeId);
218
+ if (theme.colors) {
219
+ colorText = hexToRgb(theme.colors.text) ?? COLOR_TEXT;
220
+ colorHeading = hexToRgb(theme.colors.primary) ?? COLOR_HEADING;
221
+ colorLink = hexToRgb(theme.colors.highlight || theme.colors.secondary) ?? COLOR_LINK;
222
+ }
223
+ }
224
+
192
225
  return {
193
226
  pdfDoc,
194
227
  fonts: { regular, bold, italic, boldItalic, mono, monoBold },
@@ -200,6 +233,7 @@ async function createExportContext(
200
233
  y: pageHeight - margin,
201
234
  contentWidth: pageWidth - 2 * margin,
202
235
  bottomY: margin,
236
+ colors: { text: colorText, heading: colorHeading, link: colorLink },
203
237
  };
204
238
  }
205
239
 
@@ -262,7 +296,7 @@ function flattenInlines(
262
296
  text: (node as MarkdownText).value,
263
297
  font,
264
298
  fontSize: state.code ? CODE_FONT_SIZE : ctx.fontSize,
265
- color: state.code ? COLOR_CODE_TEXT : (state.color ?? COLOR_TEXT),
299
+ color: state.code ? COLOR_CODE_TEXT : (state.color ?? ctx.colors.text),
266
300
  link: state.link,
267
301
  strikethrough: state.strikethrough,
268
302
  });
@@ -307,7 +341,7 @@ function flattenInlines(
307
341
  ...flattenInlines(linkNode.children, ctx, {
308
342
  ...state,
309
343
  link: linkNode.url,
310
- color: COLOR_LINK,
344
+ color: ctx.colors.link,
311
345
  }),
312
346
  );
313
347
  break;
@@ -329,7 +363,7 @@ function flattenInlines(
329
363
  text: '\n',
330
364
  font: ctx.fonts.regular,
331
365
  fontSize: ctx.fontSize,
332
- color: COLOR_TEXT,
366
+ color: ctx.colors.text,
333
367
  });
334
368
  break;
335
369
 
@@ -361,7 +395,7 @@ function flattenInlines(
361
395
  text: `[${ref.identifier}]`,
362
396
  font: ctx.fonts.regular,
363
397
  fontSize: ctx.fontSize * 0.75,
364
- color: COLOR_LINK,
398
+ color: ctx.colors.link,
365
399
  });
366
400
  break;
367
401
  }
@@ -376,7 +410,7 @@ function flattenInlines(
376
410
  text: fallback.value,
377
411
  font: pickFont(ctx, state.bold, state.italic),
378
412
  fontSize: ctx.fontSize,
379
- color: state.color ?? COLOR_TEXT,
413
+ color: state.color ?? ctx.colors.text,
380
414
  });
381
415
  }
382
416
  break;
@@ -601,7 +635,7 @@ function renderHeading(node: MarkdownHeading, ctx: ExportContext, extraIndent: n
601
635
  bold: true,
602
636
  italic: false,
603
637
  code: false,
604
- color: COLOR_HEADING,
638
+ color: ctx.colors.heading,
605
639
  });
606
640
 
607
641
  drawSpans(spans, ctx, w, x0);
@@ -704,7 +738,7 @@ function renderListItem(
704
738
  y: ctx.y - ctx.fontSize,
705
739
  size: ctx.fontSize,
706
740
  font: bulletFont,
707
- color: rgb(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b),
741
+ color: rgb(ctx.colors.text.r, ctx.colors.text.g, ctx.colors.text.b),
708
742
  });
709
743
 
710
744
  const textIndent = indent + bulletWidth + 4;
@@ -969,7 +1003,7 @@ function renderFootnoteDefinition(
969
1003
  y: ctx.y - ctx.fontSize * 0.75,
970
1004
  size: ctx.fontSize * 0.75,
971
1005
  font: ctx.fonts.bold,
972
- color: rgb(COLOR_LINK.r, COLOR_LINK.g, COLOR_LINK.b),
1006
+ color: rgb(ctx.colors.link.r, ctx.colors.link.g, ctx.colors.link.b),
973
1007
  });
974
1008
 
975
1009
  // Render children indented
@@ -1022,8 +1056,22 @@ function renderFallbackBlock(
1022
1056
  y: ctx.y - ctx.fontSize,
1023
1057
  size: ctx.fontSize,
1024
1058
  font: ctx.fonts.regular,
1025
- color: rgb(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b),
1059
+ color: rgb(ctx.colors.text.r, ctx.colors.text.g, ctx.colors.text.b),
1026
1060
  });
1027
1061
  ctx.y -= lineH + PARAGRAPH_SPACING;
1028
1062
  }
1029
1063
  }
1064
+
1065
+ // ============================================
1066
+ // Color Helpers
1067
+ // ============================================
1068
+
1069
+ function hexToRgb(hex: string): RgbColor | undefined {
1070
+ const h = hex.startsWith('#') ? hex.slice(1) : hex;
1071
+ if (h.length !== 6) return undefined;
1072
+ const r = parseInt(h.slice(0, 2), 16);
1073
+ const g = parseInt(h.slice(2, 4), 16);
1074
+ const b = parseInt(h.slice(4, 6), 16);
1075
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return undefined;
1076
+ return { r: r / 255, g: g / 255, b: b / 255 };
1077
+ }