@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.
- package/README.md +23 -10
- package/dist/__tests__/container.test.d.ts +8 -0
- package/dist/__tests__/container.test.d.ts.map +1 -0
- package/dist/__tests__/container.test.js +147 -0
- package/dist/__tests__/container.test.js.map +1 -0
- package/dist/{chunk-MQHCXI56.js → chunk-6OVREALI.js} +31 -13
- package/dist/chunk-6OVREALI.js.map +1 -0
- package/dist/{chunk-ULLIPBEJ.js → chunk-743COJWQ.js} +6 -2
- package/dist/chunk-743COJWQ.js.map +1 -0
- package/dist/{chunk-KAK4V57E.js → chunk-BHHEDPRB.js} +16 -112
- package/dist/chunk-BHHEDPRB.js.map +1 -0
- package/dist/chunk-FIFCQN3W.js +630 -0
- package/dist/chunk-FIFCQN3W.js.map +1 -0
- package/dist/chunk-KJ4NS4DX.js +120 -0
- package/dist/chunk-KJ4NS4DX.js.map +1 -0
- package/dist/{chunk-TBPD5PCU.js → chunk-PP5N46YD.js} +33 -11
- package/dist/chunk-PP5N46YD.js.map +1 -0
- package/dist/container/index.d.ts +36 -0
- package/dist/container/index.d.ts.map +1 -0
- package/dist/container/index.js +74 -0
- package/dist/container/index.js.map +1 -0
- package/dist/docx/export.d.ts +6 -0
- package/dist/docx/export.d.ts.map +1 -1
- package/dist/docx/export.js +24 -7
- package/dist/docx/export.js.map +1 -1
- package/dist/html/htmlTemplate.d.ts +6 -0
- package/dist/html/htmlTemplate.d.ts.map +1 -1
- package/dist/html/htmlTemplate.js +5 -1
- package/dist/html/htmlTemplate.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/ooxml/index.d.ts +1 -1
- package/dist/ooxml/index.d.ts.map +1 -1
- package/dist/ooxml/index.js +1 -1
- package/dist/ooxml/index.js.map +1 -1
- package/dist/ooxml/namespaces.d.ts +10 -0
- package/dist/ooxml/namespaces.d.ts.map +1 -1
- package/dist/ooxml/namespaces.js +10 -0
- package/dist/ooxml/namespaces.js.map +1 -1
- package/dist/pdf/export.d.ts +6 -0
- package/dist/pdf/export.d.ts.map +1 -1
- package/dist/pdf/export.js +37 -9
- package/dist/pdf/export.js.map +1 -1
- package/dist/pptx/export.d.ts +75 -0
- package/dist/pptx/export.d.ts.map +1 -0
- package/dist/pptx/export.js +722 -0
- package/dist/pptx/export.js.map +1 -0
- package/dist/pptx/index.d.ts +12 -27
- package/dist/pptx/index.d.ts.map +1 -1
- package/dist/pptx/index.js +12 -22
- package/dist/pptx/index.js.map +1 -1
- package/dist/pptx/styles.d.ts +54 -0
- package/dist/pptx/styles.d.ts.map +1 -0
- package/dist/pptx/styles.js +73 -0
- package/dist/pptx/styles.js.map +1 -0
- package/dist/pptx/templates.d.ts +24 -0
- package/dist/pptx/templates.d.ts.map +1 -0
- package/dist/pptx/templates.js +158 -0
- package/dist/pptx/templates.js.map +1 -0
- package/package.json +7 -2
- package/src/__tests__/container.test.ts +189 -0
- package/src/container/index.ts +89 -0
- package/src/docx/export.ts +36 -8
- package/src/html/htmlTemplate.ts +13 -0
- package/src/index.ts +2 -2
- package/src/ooxml/index.ts +7 -0
- package/src/ooxml/namespaces.ts +23 -0
- package/src/pdf/export.ts +57 -9
- package/src/pptx/export.ts +1041 -0
- package/src/pptx/index.ts +15 -37
- package/src/pptx/styles.ts +96 -0
- package/src/pptx/templates.ts +187 -0
- package/dist/chunk-532L4D5D.js +0 -21
- package/dist/chunk-532L4D5D.js.map +0 -1
- package/dist/chunk-KAK4V57E.js.map +0 -1
- package/dist/chunk-MQHCXI56.js.map +0 -1
- package/dist/chunk-TBPD5PCU.js.map +0 -1
- 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\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
|
+
}
|
package/src/docx/export.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
759
|
-
const headingFont =
|
|
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>` +
|
package/src/html/htmlTemplate.ts
CHANGED
|
@@ -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
|
|
package/src/ooxml/index.ts
CHANGED
|
@@ -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';
|
package/src/ooxml/namespaces.ts
CHANGED
|
@@ -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 ??
|
|
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:
|
|
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:
|
|
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:
|
|
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 ??
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|