@bendyline/squisq-formats 1.4.2 → 2.0.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.
- package/README.md +48 -2
- package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
- package/dist/chunk-2CSZJEAL.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
- package/dist/chunk-2EFDWKC6.js.map +1 -0
- package/dist/chunk-2PRVLAU3.js +740 -0
- package/dist/chunk-2PRVLAU3.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
- package/dist/chunk-4HJ6AK2Q.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
- package/dist/chunk-CVNVD7VH.js.map +1 -0
- package/dist/chunk-CX3LH47J.js +99 -0
- package/dist/chunk-CX3LH47J.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/chunk-H7T5DXUU.js +479 -0
- package/dist/chunk-H7T5DXUU.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
- package/dist/chunk-HBSCRS5P.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-X4USWOQ2.js +131 -0
- package/dist/chunk-X4USWOQ2.js.map +1 -0
- package/dist/chunk-XTLQXR4U.js +195 -0
- package/dist/chunk-XTLQXR4U.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/chunk-ZZ7D4DN6.js +97 -0
- package/dist/chunk-ZZ7D4DN6.js.map +1 -0
- package/dist/container/index.d.ts +6 -2
- package/dist/container/index.js +15 -17
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-Djev9BLU.d.ts +69 -0
- package/dist/extract-LNOA7DBK.js +15 -0
- package/dist/extract-LNOA7DBK.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-Yivc3kZM.d.ts +136 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +30 -11
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-7OUUVAGU.js +12 -0
- package/dist/layouts-7OUUVAGU.js.map +1 -0
- package/dist/layouts-CZ0bdsNL.d.ts +150 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-Gof6OrJX.d.ts +140 -0
- package/dist/registry/index.d.ts +46 -8
- package/dist/registry/index.js +5 -2
- package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +167 -0
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +59 -2
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +20 -29
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +20 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +168 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +87 -28
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1168 -0
- package/src/registry/convert.ts +54 -23
- package/src/registry/defaultFormats.ts +131 -44
- package/src/registry/index.ts +3 -0
- package/src/registry/types.ts +52 -6
- package/src/shared/boundedZipArchive.ts +342 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +97 -0
- package/src/shared/zipLimits.ts +286 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
9
|
import JSZip from 'jszip';
|
|
10
10
|
import type { Doc, Block, ImageLayer } from '@bendyline/squisq/schemas';
|
|
11
|
+
import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
|
|
11
12
|
import { docToHtml, docToHtmlZip, collectImagePaths } from '../html/index';
|
|
12
13
|
import { inferMimeType, arrayBufferToBase64DataUrl, extractFilename } from '../html/imageUtils';
|
|
13
14
|
|
|
@@ -95,6 +96,16 @@ describe('arrayBufferToBase64DataUrl', () => {
|
|
|
95
96
|
expect(result).toMatch(/^data:text\/plain;base64,/);
|
|
96
97
|
expect(result).toBe('data:text/plain;base64,aGVsbG8=');
|
|
97
98
|
});
|
|
99
|
+
|
|
100
|
+
it('encodes large buffers correctly across chunk boundaries', () => {
|
|
101
|
+
const bytes = Uint8Array.from({ length: 100_003 }, (_, index) => index % 251);
|
|
102
|
+
const result = arrayBufferToBase64DataUrl(bytes.buffer, 'application/octet-stream');
|
|
103
|
+
const decoded = Uint8Array.from(atob(result.split(',')[1]), (character) =>
|
|
104
|
+
character.charCodeAt(0),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(decoded).toEqual(bytes);
|
|
108
|
+
});
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
describe('extractFilename', () => {
|
|
@@ -225,6 +236,41 @@ describe('docToHtml', () => {
|
|
|
225
236
|
expect(html).toContain('test-doc');
|
|
226
237
|
});
|
|
227
238
|
|
|
239
|
+
it('embeds an explicitly registered theme selected by the document', () => {
|
|
240
|
+
const external = compileTheme({
|
|
241
|
+
id: 'tenant-brand',
|
|
242
|
+
name: 'External Tenant Brand',
|
|
243
|
+
seedColors: { primary: '#6633cc' },
|
|
244
|
+
});
|
|
245
|
+
const html = docToHtml(makeDoc({ themeId: external.id }), {
|
|
246
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
247
|
+
themeRegistry: createThemeRegistry([external]),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(html).toContain('External Tenant Brand');
|
|
251
|
+
expect(html).toContain('\\"themeId\\":\\"tenant-brand\\"');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('keeps a document-scoped theme ahead of the explicit registry', () => {
|
|
255
|
+
const inline = compileTheme({
|
|
256
|
+
id: 'tenant-brand',
|
|
257
|
+
name: 'Inline Tenant Brand',
|
|
258
|
+
seedColors: { primary: '#112233' },
|
|
259
|
+
});
|
|
260
|
+
const external = compileTheme({
|
|
261
|
+
id: 'tenant-brand',
|
|
262
|
+
name: 'External Tenant Brand',
|
|
263
|
+
seedColors: { primary: '#6633cc' },
|
|
264
|
+
});
|
|
265
|
+
const html = docToHtml(makeDoc({ themeId: inline.id, customThemes: [inline] }), {
|
|
266
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
267
|
+
themeRegistry: createThemeRegistry([external]),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(html).toContain('Inline Tenant Brand');
|
|
271
|
+
expect(html).not.toContain('External Tenant Brand');
|
|
272
|
+
});
|
|
273
|
+
|
|
228
274
|
it('calls SquisqPlayer.mount', () => {
|
|
229
275
|
const doc = makeDoc();
|
|
230
276
|
const html = docToHtml(doc, { playerScript: MOCK_PLAYER_SCRIPT });
|
|
@@ -310,6 +356,22 @@ describe('docToHtmlZip', () => {
|
|
|
310
356
|
expect(html).not.toContain(MOCK_PLAYER_SCRIPT);
|
|
311
357
|
});
|
|
312
358
|
|
|
359
|
+
it('embeds a caller-owned theme selected by the document', async () => {
|
|
360
|
+
const external = compileTheme({
|
|
361
|
+
id: 'tenant-zip-brand',
|
|
362
|
+
name: 'Tenant ZIP Brand',
|
|
363
|
+
seedColors: { primary: '#224466' },
|
|
364
|
+
});
|
|
365
|
+
const blob = await docToHtmlZip(makeDoc({ themeId: external.id }), {
|
|
366
|
+
playerScript: MOCK_PLAYER_SCRIPT,
|
|
367
|
+
themeRegistry: createThemeRegistry([external]),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const zip = await JSZip.loadAsync(await blobToUint8Array(blob));
|
|
371
|
+
const html = await zip.file('index.html')!.async('text');
|
|
372
|
+
expect(html).toContain('Tenant ZIP Brand');
|
|
373
|
+
});
|
|
374
|
+
|
|
313
375
|
it('preserves the original image path inside the zip', async () => {
|
|
314
376
|
// Direct `<img src="folder/file.png">` references in the rendered HTML
|
|
315
377
|
// resolve only if the zip mirrors the doc's path layout — flattening
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `inferThemeFromFile`: format sniffing, per-format extraction
|
|
3
|
+
* (incl. dark-deck clrMap inversion), theme compilation, and error paths.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { ConversionError } from '../registry/errors';
|
|
8
|
+
import { inferThemeFromFile } from '../infer/index';
|
|
9
|
+
import {
|
|
10
|
+
buildThemeXml,
|
|
11
|
+
buildThemedDocx,
|
|
12
|
+
buildThemedPptx,
|
|
13
|
+
buildThemedXlsx,
|
|
14
|
+
} from './pptxInferFixtures';
|
|
15
|
+
|
|
16
|
+
describe('inferThemeFromFile — PPTX', () => {
|
|
17
|
+
it('maps a light deck onto seeds, palette, schemes, and fonts', async () => {
|
|
18
|
+
const data = await buildThemedPptx();
|
|
19
|
+
const result = await inferThemeFromFile(data);
|
|
20
|
+
|
|
21
|
+
expect(result.extraction.sourceFormat).toBe('pptx');
|
|
22
|
+
expect(result.extraction.colorMap).toEqual({ bg1: 'lt1', tx1: 'dk1' });
|
|
23
|
+
|
|
24
|
+
const { theme } = result;
|
|
25
|
+
expect(theme.seedColors).toMatchObject({
|
|
26
|
+
primary: '#4472c4',
|
|
27
|
+
secondary: '#ed7d31',
|
|
28
|
+
accent: '#a5a5a5',
|
|
29
|
+
background: '#fdfdf8',
|
|
30
|
+
text: '#1a1a2e',
|
|
31
|
+
});
|
|
32
|
+
expect(theme.colors.background).toBe('#fdfdf8');
|
|
33
|
+
expect(theme.colors.text).toBe('#1a1a2e');
|
|
34
|
+
// Companion slot (lt2) becomes backgroundLight; hlink becomes highlight.
|
|
35
|
+
expect(theme.colors.backgroundLight).toBe('#efefe4');
|
|
36
|
+
expect(theme.colors.highlight).toBe('#0563c1');
|
|
37
|
+
// Six accents → six color schemes keyed by slot.
|
|
38
|
+
expect(Object.keys(theme.colorSchemes)).toEqual([
|
|
39
|
+
'accent1',
|
|
40
|
+
'accent2',
|
|
41
|
+
'accent3',
|
|
42
|
+
'accent4',
|
|
43
|
+
'accent5',
|
|
44
|
+
'accent6',
|
|
45
|
+
]);
|
|
46
|
+
// Fonts: curated stack match + custom preservation.
|
|
47
|
+
expect(theme.typography.titleFont).toEqual({ stackId: 'playfair' });
|
|
48
|
+
expect(theme.typography.bodyFont).toEqual({
|
|
49
|
+
custom: { name: 'Aptos', fallback: 'sans-serif' },
|
|
50
|
+
});
|
|
51
|
+
// Compiled + validated, named from the theme part, re-editable via seeds.
|
|
52
|
+
expect(theme.name).toBe('Fixture Theme');
|
|
53
|
+
expect(theme.id).toBe('custom-fixture-theme');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('inverts background/text for a dark deck (clrMap bg1="dk1")', async () => {
|
|
57
|
+
const data = await buildThemedPptx({
|
|
58
|
+
clrMapAttrs: 'bg1="dk1" tx1="lt1" bg2="dk2" tx2="lt2"',
|
|
59
|
+
});
|
|
60
|
+
const result = await inferThemeFromFile(data);
|
|
61
|
+
expect(result.theme.seedColors?.background).toBe('#1a1a2e');
|
|
62
|
+
expect(result.theme.seedColors?.text).toBe('#fdfdf8');
|
|
63
|
+
// Dark companion slot backs backgroundLight.
|
|
64
|
+
expect(result.theme.colors.backgroundLight).toBe('#30304a');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('respects a nameHint over the file theme name', async () => {
|
|
68
|
+
const result = await inferThemeFromFile(await buildThemedPptx(), { nameHint: 'Q3 Deck' });
|
|
69
|
+
expect(result.theme.name).toBe('Q3 Deck');
|
|
70
|
+
expect(result.theme.id).toBe('custom-q3-deck');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('throws invalid-input when the file has no theme part', async () => {
|
|
74
|
+
const data = await buildThemedPptx({ themeXml: null });
|
|
75
|
+
await expect(inferThemeFromFile(data)).rejects.toMatchObject({
|
|
76
|
+
name: 'ConversionError',
|
|
77
|
+
code: 'invalid-input',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('inferThemeFromFile — DOCX / XLSX', () => {
|
|
83
|
+
it('extracts from a DOCX theme part', async () => {
|
|
84
|
+
const result = await inferThemeFromFile(await buildThemedDocx());
|
|
85
|
+
expect(result.extraction.sourceFormat).toBe('docx');
|
|
86
|
+
expect(result.theme.seedColors?.primary).toBe('#4472c4');
|
|
87
|
+
expect(result.theme.colors.background).toBe('#fdfdf8');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('extracts from an XLSX theme part', async () => {
|
|
91
|
+
const result = await inferThemeFromFile(await buildThemedXlsx());
|
|
92
|
+
expect(result.extraction.sourceFormat).toBe('xlsx');
|
|
93
|
+
expect(result.theme.seedColors?.primary).toBe('#4472c4');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores inferLayouts for non-PPTX sources with a warning', async () => {
|
|
97
|
+
const result = await inferThemeFromFile(await buildThemedDocx(), { inferLayouts: true });
|
|
98
|
+
expect(result.layouts).toBeUndefined();
|
|
99
|
+
expect(result.warnings.some((w) => w.includes('PowerPoint'))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('inferThemeFromFile — rejection paths', () => {
|
|
104
|
+
it('rejects PDF bytes with unsupported-input', async () => {
|
|
105
|
+
const pdf = new TextEncoder().encode('%PDF-1.4 fake').buffer as ArrayBuffer;
|
|
106
|
+
await expect(inferThemeFromFile(pdf)).rejects.toMatchObject({
|
|
107
|
+
code: 'unsupported-input',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects non-zip bytes with invalid-input', async () => {
|
|
112
|
+
const junk = new TextEncoder().encode('definitely not a zip').buffer as ArrayBuffer;
|
|
113
|
+
const err = await inferThemeFromFile(junk).catch((e: unknown) => e);
|
|
114
|
+
expect(err).toBeInstanceOf(ConversionError);
|
|
115
|
+
expect((err as ConversionError).code).toBe('invalid-input');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('inferThemeFromFile — degraded themes', () => {
|
|
120
|
+
it('drops near-background accents from the scheme list with a warning', async () => {
|
|
121
|
+
// accent2 equals the background color — should be dropped from schemes.
|
|
122
|
+
const themeXml = buildThemeXml({ accents: ['4472c4', 'fdfdf8', 'a5a5a5'] });
|
|
123
|
+
const result = await inferThemeFromFile(await buildThemedPptx({ themeXml }));
|
|
124
|
+
expect(Object.keys(result.theme.colorSchemes)).toEqual(['accent1', 'accent3']);
|
|
125
|
+
expect(result.warnings.some((w) => w.includes('accent2'))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('keeps fonts inherited when the theme has no fontScheme', async () => {
|
|
129
|
+
const themeXml = buildThemeXml({ omitFontScheme: true });
|
|
130
|
+
const result = await inferThemeFromFile(await buildThemedPptx({ themeXml }));
|
|
131
|
+
// STARTER defaults survive.
|
|
132
|
+
expect(result.theme.typography.titleFont).toEqual({ stackId: 'system-serif' });
|
|
133
|
+
expect(result.warnings.some((w) => w.includes('fontScheme'))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -8,8 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
10
10
|
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
11
|
+
import type { MarkdownDocument } from '@bendyline/squisq/markdown';
|
|
11
12
|
import { convert, defaultRegistry } from '../registry/index';
|
|
12
13
|
|
|
14
|
+
// ── shared Markdown exporter capability profiles ───────────────────
|
|
15
|
+
|
|
16
|
+
describe('Markdown exporters warn about unsupported AST extensions', () => {
|
|
17
|
+
const extendedMarkdown: MarkdownDocument = {
|
|
18
|
+
type: 'document',
|
|
19
|
+
children: [
|
|
20
|
+
{
|
|
21
|
+
type: 'paragraph',
|
|
22
|
+
children: [
|
|
23
|
+
{ type: 'text', value: 'Assigned to ' },
|
|
24
|
+
{
|
|
25
|
+
type: 'mention',
|
|
26
|
+
targetKind: 'user',
|
|
27
|
+
targetId: '42',
|
|
28
|
+
displayName: 'Ada',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'containerDirective',
|
|
34
|
+
name: 'note',
|
|
35
|
+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Important' }] }],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it('surfaces the omitted node types through ConversionResult', async () => {
|
|
41
|
+
const result = await convert({ kind: 'markdown', markdown: extendedMarkdown }, 'docx');
|
|
42
|
+
const warning = result.warnings.find((item) => /unsupported Markdown node/i.test(item));
|
|
43
|
+
|
|
44
|
+
expect(warning).toMatch(/mention \(1\)/);
|
|
45
|
+
expect(warning).toMatch(/containerDirective \(1\)/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not warn for the supported Markdown subset', async () => {
|
|
49
|
+
const result = await convert({ kind: 'markdown', markdown: '# Title\n\nBody.' }, 'docx');
|
|
50
|
+
expect(result.warnings.some((item) => /unsupported Markdown node/i.test(item))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
13
54
|
// ── xlsx: tables-only fidelity ──────────────────────────────────────
|
|
14
55
|
|
|
15
56
|
describe('xlsx export warns about omitted non-table content', () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for the shared OOXML layer: xmlUtils, writer, reader round-trips.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
6
6
|
import {
|
|
7
7
|
escapeXml,
|
|
8
8
|
xmlElement,
|
|
@@ -15,6 +15,10 @@ import { createPackage } from '../ooxml/writer';
|
|
|
15
15
|
import { openPackage, getPartXml, getCoreProperties, getPartRelationships } from '../ooxml/reader';
|
|
16
16
|
import { REL_OFFICE_DOCUMENT } from '../ooxml/namespaces';
|
|
17
17
|
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
18
22
|
// ============================================
|
|
19
23
|
// XML Utilities
|
|
20
24
|
// ============================================
|
|
@@ -97,6 +101,45 @@ describe('xmlDeclaration', () => {
|
|
|
97
101
|
// ============================================
|
|
98
102
|
|
|
99
103
|
describe('createPackage / openPackage round-trip', () => {
|
|
104
|
+
it('rejects unsafe authored part paths', () => {
|
|
105
|
+
const pkg = createPackage();
|
|
106
|
+
expect(() => pkg.addPart('../outside.xml', '<doc/>', 'application/xml')).toThrow(
|
|
107
|
+
/path is unsafe/i,
|
|
108
|
+
);
|
|
109
|
+
expect(() =>
|
|
110
|
+
pkg.addRelationship('..\\outside.xml', {
|
|
111
|
+
id: 'rId1',
|
|
112
|
+
type: REL_OFFICE_DOCUMENT,
|
|
113
|
+
target: 'word/document.xml',
|
|
114
|
+
}),
|
|
115
|
+
).toThrow(/path is unsafe/i);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('parses packages when the browser DOMParser global is unavailable', async () => {
|
|
119
|
+
const pkg = createPackage();
|
|
120
|
+
pkg.addPart('word/document.xml', '<doc><value>node</value></doc>', 'application/xml');
|
|
121
|
+
const buffer = await pkg.toArrayBuffer();
|
|
122
|
+
|
|
123
|
+
vi.stubGlobal('DOMParser', undefined);
|
|
124
|
+
const opened = await openPackage(buffer);
|
|
125
|
+
const doc = await getPartXml(opened, 'word/document.xml');
|
|
126
|
+
expect(doc?.getElementsByTagName('value')[0]?.textContent).toBe('node');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('applies archive resource limits before parsing OOXML parts', async () => {
|
|
130
|
+
const pkg = createPackage();
|
|
131
|
+
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
132
|
+
const buffer = await pkg.toArrayBuffer();
|
|
133
|
+
|
|
134
|
+
await expect(openPackage(buffer, { maxEntries: 1 })).rejects.toMatchObject({
|
|
135
|
+
code: 'too-many-entries',
|
|
136
|
+
actual: 3,
|
|
137
|
+
});
|
|
138
|
+
await expect(openPackage(buffer, { maxUncompressedBytes: 1 })).rejects.toThrow(
|
|
139
|
+
/uncompressed content exceeds 1 byte limit/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
100
143
|
it('creates a valid ZIP with content types and parts', async () => {
|
|
101
144
|
const pkg = createPackage();
|
|
102
145
|
pkg.addPart(
|
|
@@ -116,7 +159,7 @@ describe('createPackage / openPackage round-trip', () => {
|
|
|
116
159
|
|
|
117
160
|
// Read it back
|
|
118
161
|
const opened = await openPackage(arrayBuffer);
|
|
119
|
-
expect(opened
|
|
162
|
+
expect(opened).not.toHaveProperty('zip');
|
|
120
163
|
expect(opened.contentTypes.overrides.has('word/document.xml')).toBe(true);
|
|
121
164
|
expect(opened.rootRelationships.length).toBeGreaterThanOrEqual(1);
|
|
122
165
|
expect(opened.rootRelationships[0].type).toBe(REL_OFFICE_DOCUMENT);
|
|
@@ -156,6 +199,20 @@ describe('createPackage / openPackage round-trip', () => {
|
|
|
156
199
|
expect(props.description).toBe('Test description with <special> & chars');
|
|
157
200
|
});
|
|
158
201
|
|
|
202
|
+
it('serializes repeatedly without accumulating generated relationships', async () => {
|
|
203
|
+
const pkg = createPackage();
|
|
204
|
+
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
205
|
+
pkg.setCoreProperties({ title: 'Stable' });
|
|
206
|
+
|
|
207
|
+
await pkg.toArrayBuffer();
|
|
208
|
+
const second = await pkg.toArrayBuffer();
|
|
209
|
+
const opened = await openPackage(second);
|
|
210
|
+
const coreRelationships = opened.rootRelationships.filter((rel) =>
|
|
211
|
+
rel.type.endsWith('/metadata/core-properties'),
|
|
212
|
+
);
|
|
213
|
+
expect(coreRelationships).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
159
216
|
it('round-trips part relationships', async () => {
|
|
160
217
|
const pkg = createPackage();
|
|
161
218
|
pkg.addPart('word/document.xml', '<doc/>', 'application/xml');
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared OOXML theme reader: clrScheme slot resolution
|
|
3
|
+
* (srgbClr / sysClr / fallbacks) and fontScheme parsing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { NS_DRAWINGML } from '../ooxml/namespaces';
|
|
8
|
+
import { parseThemeXml } from '../ooxml/themeReader';
|
|
9
|
+
import { buildThemeXml } from './pptxInferFixtures';
|
|
10
|
+
|
|
11
|
+
function parse(xml: string): Document {
|
|
12
|
+
return new DOMParser().parseFromString(xml, 'application/xml');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('parseThemeXml', () => {
|
|
16
|
+
it('reads srgbClr slots, theme name, and latin fonts', () => {
|
|
17
|
+
const theme = parseThemeXml(parse(buildThemeXml({ name: 'Ion' })));
|
|
18
|
+
expect(theme.name).toBe('Ion');
|
|
19
|
+
expect(theme.colors).toMatchObject({
|
|
20
|
+
dk1: '#1a1a2e',
|
|
21
|
+
lt1: '#fdfdf8',
|
|
22
|
+
dk2: '#30304a',
|
|
23
|
+
lt2: '#efefe4',
|
|
24
|
+
accent1: '#4472c4',
|
|
25
|
+
accent6: '#70ad47',
|
|
26
|
+
hlink: '#0563c1',
|
|
27
|
+
});
|
|
28
|
+
expect(theme.fonts).toEqual({ majorLatin: 'Playfair Display', minorLatin: 'Aptos' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('prefers sysClr lastClr when present', () => {
|
|
32
|
+
const xml = buildThemeXml({
|
|
33
|
+
dk1: '<a:sysClr val="windowText" lastClr="0B0B0B"/>',
|
|
34
|
+
lt1: '<a:sysClr val="window" lastClr="FAFAFA"/>',
|
|
35
|
+
});
|
|
36
|
+
const theme = parseThemeXml(parse(xml));
|
|
37
|
+
expect(theme.colors?.dk1).toBe('#0b0b0b');
|
|
38
|
+
expect(theme.colors?.lt1).toBe('#fafafa');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('resolves windowText/window sysClr without lastClr', () => {
|
|
42
|
+
const xml = buildThemeXml({
|
|
43
|
+
dk1: '<a:sysClr val="windowText"/>',
|
|
44
|
+
lt1: '<a:sysClr val="window"/>',
|
|
45
|
+
});
|
|
46
|
+
const theme = parseThemeXml(parse(xml));
|
|
47
|
+
expect(theme.colors?.dk1).toBe('#000000');
|
|
48
|
+
expect(theme.colors?.lt1).toBe('#ffffff');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back with a warning for unknown sysClr values', () => {
|
|
52
|
+
const xml = buildThemeXml({ dk1: '<a:sysClr val="btnFace"/>' });
|
|
53
|
+
const theme = parseThemeXml(parse(xml));
|
|
54
|
+
expect(theme.colors?.dk1).toBe('#000000');
|
|
55
|
+
expect(theme.warnings.some((w) => w.includes('dk1'))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('drops unresolvable accent slots with warnings', () => {
|
|
59
|
+
// accent list of 2 → accent3..6 slots missing entirely.
|
|
60
|
+
const xml = buildThemeXml({ accents: ['112233', '445566'] });
|
|
61
|
+
const theme = parseThemeXml(parse(xml));
|
|
62
|
+
expect(theme.colors?.accent1).toBe('#112233');
|
|
63
|
+
expect(theme.colors?.accent2).toBe('#445566');
|
|
64
|
+
expect(theme.colors?.accent3).toBeUndefined();
|
|
65
|
+
expect(theme.warnings.some((w) => w.includes('accent3'))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles a missing fontScheme', () => {
|
|
69
|
+
const theme = parseThemeXml(parse(buildThemeXml({ omitFontScheme: true })));
|
|
70
|
+
expect(theme.fonts).toBeUndefined();
|
|
71
|
+
expect(theme.warnings.some((w) => w.includes('fontScheme'))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats an empty typeface as absent', () => {
|
|
75
|
+
const xml =
|
|
76
|
+
`<a:theme xmlns:a="${NS_DRAWINGML}" name="T"><a:themeElements>` +
|
|
77
|
+
`<a:fontScheme name="T">` +
|
|
78
|
+
`<a:majorFont><a:latin typeface=""/></a:majorFont>` +
|
|
79
|
+
`<a:minorFont><a:latin typeface="Georgia"/></a:minorFont>` +
|
|
80
|
+
`</a:fontScheme></a:themeElements></a:theme>`;
|
|
81
|
+
const theme = parseThemeXml(parse(xml));
|
|
82
|
+
expect(theme.fonts).toEqual({ minorLatin: 'Georgia' });
|
|
83
|
+
expect(theme.warnings.some((w) => w.includes('majorFont'))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reports a theme with no clrScheme', () => {
|
|
87
|
+
const xml = `<a:theme xmlns:a="${NS_DRAWINGML}" name="Empty"><a:themeElements/></a:theme>`;
|
|
88
|
+
const theme = parseThemeXml(parse(xml));
|
|
89
|
+
expect(theme.colors).toBeUndefined();
|
|
90
|
+
expect(theme.warnings.some((w) => w.includes('clrScheme'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect } from 'vitest';
|
|
6
6
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
7
|
-
import { resolveTheme } from '@bendyline/squisq/schemas';
|
|
7
|
+
import { compileTheme, createThemeRegistry, resolveTheme } from '@bendyline/squisq/schemas';
|
|
8
8
|
import { markdownDocToPlainHtml } from '../html/plainHtml';
|
|
9
9
|
|
|
10
10
|
function render(md: string, options?: Parameters<typeof markdownDocToPlainHtml>[1]): string {
|
|
@@ -26,6 +26,20 @@ describe('markdownDocToPlainHtml', () => {
|
|
|
26
26
|
expect(html).toContain('<title>A <script> & more</title>');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it('resolves custom themes only through an explicit caller-owned registry', () => {
|
|
30
|
+
const custom = compileTheme({
|
|
31
|
+
id: 'tenant-theme',
|
|
32
|
+
name: 'Tenant Theme',
|
|
33
|
+
seedColors: { primary: '#123456' },
|
|
34
|
+
});
|
|
35
|
+
const registry = createThemeRegistry([custom]);
|
|
36
|
+
const withoutRegistry = render('# Title', { themeId: 'tenant-theme' });
|
|
37
|
+
const withRegistry = render('# Title', { themeId: 'tenant-theme', themeRegistry: registry });
|
|
38
|
+
|
|
39
|
+
expect(withoutRegistry).not.toContain('--plain-primary: #123456');
|
|
40
|
+
expect(withRegistry).toContain('--plain-primary: #123456');
|
|
41
|
+
});
|
|
42
|
+
|
|
29
43
|
it('renders headings at the correct depth', () => {
|
|
30
44
|
const html = render('# One\n\n## Two\n\n### Three\n\n#### Four\n\n##### Five\n\n###### Six');
|
|
31
45
|
expect(html).toContain('<h1>One</h1>');
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect } from 'vitest';
|
|
12
12
|
import JSZip from 'jszip';
|
|
13
|
+
import { compileTheme, createThemeRegistry } from '@bendyline/squisq/schemas';
|
|
13
14
|
import { markdownDocsToPlainHtmlBundle } from '../html/plainHtmlBundle';
|
|
14
15
|
|
|
15
16
|
/** Helper to read a Blob as Uint8Array (works in jsdom). */
|
|
@@ -58,6 +59,23 @@ describe('markdownDocsToPlainHtmlBundle', () => {
|
|
|
58
59
|
expect(html).toContain('<h1>Home</h1>');
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
it('resolves a caller-owned theme without process-global registration', async () => {
|
|
63
|
+
const theme = compileTheme({
|
|
64
|
+
id: 'tenant-brand',
|
|
65
|
+
name: 'Tenant Brand',
|
|
66
|
+
seedColors: { primary: '#123456' },
|
|
67
|
+
});
|
|
68
|
+
const c = makeContainer({ 'home.md': '# Home' });
|
|
69
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
70
|
+
entryPath: 'home.md',
|
|
71
|
+
themeId: theme.id,
|
|
72
|
+
themeRegistry: createThemeRegistry([theme]),
|
|
73
|
+
...c,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(await readZipPath(blob, 'home.html')).toContain('--plain-primary: #123456');
|
|
77
|
+
});
|
|
78
|
+
|
|
61
79
|
it('follows a linear chain home.md → resume.md and bundles both', async () => {
|
|
62
80
|
const c = makeContainer({
|
|
63
81
|
'home.md': '# Home\n\nMy [resume](resume.md) is here.',
|
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
MarkdownParagraph,
|
|
12
12
|
MarkdownText,
|
|
13
13
|
} from '@bendyline/squisq/markdown';
|
|
14
|
+
import { readCustomThemesFromFrontmatter } from '@bendyline/squisq/doc';
|
|
14
15
|
import { NS_DRAWINGML, NS_PML, NS_R, REL_OFFICE_DOCUMENT, REL_SLIDE } from '../ooxml/namespaces';
|
|
15
16
|
import { createPackage } from '../ooxml/writer';
|
|
16
17
|
import { xmlDeclaration } from '../ooxml/xmlUtils';
|
|
@@ -113,3 +114,32 @@ describe('pptxToContainer (embedded image import)', () => {
|
|
|
113
114
|
expect(hasImage).toBe(false);
|
|
114
115
|
});
|
|
115
116
|
});
|
|
117
|
+
|
|
118
|
+
describe('theme inference defaults (ON)', () => {
|
|
119
|
+
const simpleDoc: MarkdownDocument = {
|
|
120
|
+
type: 'document',
|
|
121
|
+
children: [{ type: 'heading', depth: 2, children: [{ type: 'text', value: 'Hello' }] }],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
it('carries the deck theme in frontmatter on an export → import round trip', async () => {
|
|
125
|
+
const deck = await markdownDocToPptx(simpleDoc, {});
|
|
126
|
+
const imported = await pptxToMarkdownDoc(deck);
|
|
127
|
+
// The exporter writes the stock Office theme part; import infers it.
|
|
128
|
+
expect(imported.frontmatter?.['squisq-theme']).toBe('custom-office-theme');
|
|
129
|
+
const themes = readCustomThemesFromFrontmatter(imported.frontmatter);
|
|
130
|
+
expect(themes).toHaveLength(1);
|
|
131
|
+
expect(themes![0]!.seedColors?.primary).toBe('#4472c4');
|
|
132
|
+
expect(themes![0]!.seedColors?.background).toBe('#ffffff');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('produces the legacy frontmatter-free import when inference is disabled', async () => {
|
|
136
|
+
const deck = await markdownDocToPptx(simpleDoc, {});
|
|
137
|
+
const imported = await pptxToMarkdownDoc(deck, { inferTheme: false, inferLayouts: false });
|
|
138
|
+
expect(imported.frontmatter).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('imports decks without theme parts cleanly (no frontmatter, no failure)', async () => {
|
|
142
|
+
const doc = await pptxToMarkdownDoc(await buildTestPptx());
|
|
143
|
+
expect(doc.frontmatter).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|